@checkstack/backend-api 0.20.0 → 0.21.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/package.json +12 -11
  3. package/src/auth-strategy.ts +6 -3
  4. package/src/bearer-token.ts +13 -0
  5. package/src/collector-strategy.ts +9 -0
  6. package/src/config-versioning.test.ts +227 -0
  7. package/src/config-versioning.ts +172 -0
  8. package/src/core-services.ts +14 -0
  9. package/src/esm-script-runner.test.ts +55 -16
  10. package/src/esm-script-runner.ts +212 -55
  11. package/src/index.ts +3 -0
  12. package/src/render-templatable-config.test.ts +168 -0
  13. package/src/render-templatable-config.ts +193 -0
  14. package/src/schema-utils.ts +3 -0
  15. package/src/script-sandbox/capabilities.test.ts +122 -0
  16. package/src/script-sandbox/capabilities.ts +372 -0
  17. package/src/script-sandbox/capped-output.test.ts +116 -0
  18. package/src/script-sandbox/capped-output.ts +172 -0
  19. package/src/script-sandbox/env-guard.test.ts +105 -0
  20. package/src/script-sandbox/env-guard.ts +129 -0
  21. package/src/script-sandbox/filesystem.test.ts +437 -0
  22. package/src/script-sandbox/filesystem.ts +514 -0
  23. package/src/script-sandbox/forkbomb.it.test.ts +121 -0
  24. package/src/script-sandbox/global-default.test.ts +161 -0
  25. package/src/script-sandbox/global-default.ts +100 -0
  26. package/src/script-sandbox/index.ts +14 -0
  27. package/src/script-sandbox/network.test.ts +356 -0
  28. package/src/script-sandbox/network.ts +373 -0
  29. package/src/script-sandbox/observability.test.ts +210 -0
  30. package/src/script-sandbox/observability.ts +168 -0
  31. package/src/script-sandbox/output-truncation.test.ts +53 -0
  32. package/src/script-sandbox/output-truncation.ts +69 -0
  33. package/src/script-sandbox/policy.test.ts +189 -0
  34. package/src/script-sandbox/policy.ts +220 -0
  35. package/src/script-sandbox/provider.test.ts +61 -0
  36. package/src/script-sandbox/provider.ts +134 -0
  37. package/src/script-sandbox/readiness.test.ts +80 -0
  38. package/src/script-sandbox/readiness.ts +117 -0
  39. package/src/script-sandbox/report.ts +88 -0
  40. package/src/script-sandbox/rootless-egress.it.test.ts +86 -0
  41. package/src/script-sandbox/rootless-egress.test.ts +99 -0
  42. package/src/script-sandbox/rootless-egress.ts +218 -0
  43. package/src/script-sandbox/shell-quote.test.ts +32 -0
  44. package/src/script-sandbox/shell-quote.ts +10 -0
  45. package/src/script-sandbox/wrapper.test.ts +1194 -0
  46. package/src/script-sandbox/wrapper.ts +714 -0
  47. package/src/shell-script-runner.test.ts +243 -0
  48. package/src/shell-script-runner.ts +210 -45
  49. package/src/zod-config.test.ts +60 -0
  50. package/src/zod-config.ts +38 -14
  51. package/tsconfig.json +3 -0
@@ -0,0 +1,193 @@
1
+ import { z } from "zod";
2
+ import {
3
+ renderTemplatePreview,
4
+ type TemplateContext,
5
+ } from "@checkstack/template-engine";
6
+
7
+ import { getConfigMeta } from "./zod-config";
8
+
9
+ // Re-export the canonical preview helper so backend consumers can render a
10
+ // single templatable value (editor-preview / diagnostics) without reaching
11
+ // into `@checkstack/template-engine` directly. The frontend imports it from
12
+ // the template engine. Both share one implementation, so previews never
13
+ // diverge from the run-time render.
14
+ export { renderTemplatePreview } from "@checkstack/template-engine";
15
+
16
+ /**
17
+ * Shared per-environment templating pass for collector / strategy config.
18
+ *
19
+ * Walks a validated config object against its zod schema and, for each STRING
20
+ * field marked `x-templatable`, renders its value through the template engine
21
+ * against the supplied `context` (`{ environment, check, system }`). Every
22
+ * other field is passed through verbatim, so a literal `{{` in a
23
+ * non-templatable field is never touched.
24
+ *
25
+ * This pass runs PER ENVIRONMENT in the executor, AFTER the secret-render pass
26
+ * and BEFORE the strategy client build / collector execute, so each resolved
27
+ * environment gets its own rendered config (see the queue-executor fan-out
28
+ * loop). The two syntaxes are lexically distinct and resolved in separate
29
+ * ordered stages:
30
+ *
31
+ * - `${{ secrets.NAME }}` — resolved FIRST by the secrets resolver, in
32
+ * `x-secret` / `x-secret-env` fields only.
33
+ * - `{{ environment.* }}` / `{{ check.* }}` / `{{ system.* }}` — resolved
34
+ * SECOND by this pass, in `x-templatable` fields only.
35
+ *
36
+ * A field marked both `x-secret`(-env) and `x-templatable` is a load-time
37
+ * config error (see {@link assertNoSecretTemplatableConflict}), so the two
38
+ * passes always touch disjoint fields.
39
+ */
40
+
41
+ /** Strip Optional / Default / Nullable wrappers to reach the inner schema. */
42
+ function unwrap(schema: z.ZodTypeAny): z.ZodTypeAny {
43
+ let current = schema;
44
+ // Loop because wrappers can nest (e.g. `.optional().default()`).
45
+ for (;;) {
46
+ if (current instanceof z.ZodOptional || current instanceof z.ZodNullable) {
47
+ current = current.unwrap() as z.ZodTypeAny;
48
+ continue;
49
+ }
50
+ if (current instanceof z.ZodDefault) {
51
+ current = current.def.innerType as z.ZodTypeAny;
52
+ continue;
53
+ }
54
+ return current;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Recursively walk a value against its zod schema, rendering `x-templatable`
60
+ * string fields. Returns a NEW value; the input is never mutated.
61
+ */
62
+ function walk({
63
+ value,
64
+ schema,
65
+ context,
66
+ }: {
67
+ value: unknown;
68
+ schema: z.ZodTypeAny;
69
+ context: TemplateContext;
70
+ }): unknown {
71
+ const inner = unwrap(schema);
72
+
73
+ // Array — render each element against the element schema.
74
+ if (inner instanceof z.ZodArray) {
75
+ if (!Array.isArray(value)) return value;
76
+ const element = (inner as z.ZodArray<z.ZodTypeAny>).element;
77
+ return value.map((item) => walk({ value: item, schema: element, context }));
78
+ }
79
+
80
+ // Object — render each known property against its field schema.
81
+ if (inner instanceof z.ZodObject) {
82
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
83
+ return value;
84
+ }
85
+ const shape = (inner as z.ZodObject<z.ZodRawShape>).shape;
86
+ const source = value as Record<string, unknown>;
87
+ const next: Record<string, unknown> = { ...source };
88
+ for (const [key, fieldSchema] of Object.entries(shape)) {
89
+ if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
90
+ next[key] = walk({
91
+ value: source[key],
92
+ schema: fieldSchema as z.ZodTypeAny,
93
+ context,
94
+ });
95
+ }
96
+ return next;
97
+ }
98
+
99
+ // Leaf string — render only when the field is marked templatable. The meta
100
+ // lives on the ORIGINAL (possibly-wrapped) schema, so look it up on `schema`
101
+ // (getConfigMeta unwraps internally).
102
+ if (typeof value === "string" && getConfigMeta(schema)?.["x-templatable"]) {
103
+ return renderTemplatePreview({ value, context });
104
+ }
105
+
106
+ return value;
107
+ }
108
+
109
+ /**
110
+ * Render every `x-templatable` string field in `config` against `context`.
111
+ * Returns a new config object; non-templatable fields are passed through
112
+ * verbatim.
113
+ */
114
+ export function renderTemplatableConfig({
115
+ config,
116
+ schema,
117
+ context,
118
+ }: {
119
+ config: unknown;
120
+ schema: z.ZodType<unknown>;
121
+ context: TemplateContext;
122
+ }): unknown {
123
+ return walk({ value: config, schema: schema as z.ZodTypeAny, context });
124
+ }
125
+
126
+ /**
127
+ * Load-time guard: a config field MUST NOT carry both a secret marker
128
+ * (`x-secret` or `x-secret-env`) and `x-templatable`. They are resolved in
129
+ * separate ordered passes (secrets first, templating second) and must never
130
+ * combine. Throws on the first conflicting field, naming its path.
131
+ *
132
+ * Call this once per registered collector / strategy config schema at plugin
133
+ * load time so a misconfigured field fails fast instead of silently skipping
134
+ * one of the two passes at run time.
135
+ */
136
+ export function assertNoSecretTemplatableConflict({
137
+ schema,
138
+ schemaName,
139
+ }: {
140
+ schema: z.ZodType<unknown>;
141
+ schemaName: string;
142
+ }): void {
143
+ const conflict = findSecretTemplatableConflict({
144
+ schema: schema as z.ZodTypeAny,
145
+ path: "",
146
+ });
147
+ if (conflict) {
148
+ throw new Error(
149
+ `Config schema "${schemaName}" field "${conflict}" is marked both ` +
150
+ `as a secret (x-secret / x-secret-env) and x-templatable. Secrets ` +
151
+ `and templating are resolved in separate passes and must not be ` +
152
+ `combined on the same field.`,
153
+ );
154
+ }
155
+ }
156
+
157
+ function findSecretTemplatableConflict({
158
+ schema,
159
+ path,
160
+ }: {
161
+ schema: z.ZodTypeAny;
162
+ path: string;
163
+ }): string | undefined {
164
+ const inner = unwrap(schema);
165
+
166
+ if (inner instanceof z.ZodArray) {
167
+ return findSecretTemplatableConflict({
168
+ schema: (inner as z.ZodArray<z.ZodTypeAny>).element,
169
+ path: `${path}[]`,
170
+ });
171
+ }
172
+
173
+ if (inner instanceof z.ZodObject) {
174
+ const shape = (inner as z.ZodObject<z.ZodRawShape>).shape;
175
+ for (const [key, fieldSchema] of Object.entries(shape)) {
176
+ const fieldPath = path ? `${path}.${key}` : key;
177
+ const meta = getConfigMeta(fieldSchema as z.ZodTypeAny);
178
+ const isSecret =
179
+ meta?.["x-secret"] === true || meta?.["x-secret-env"] === true;
180
+ if (isSecret && meta?.["x-templatable"] === true) {
181
+ return fieldPath;
182
+ }
183
+ const nested = findSecretTemplatableConflict({
184
+ schema: fieldSchema as z.ZodTypeAny,
185
+ path: fieldPath,
186
+ });
187
+ if (nested) return nested;
188
+ }
189
+ return undefined;
190
+ }
191
+
192
+ return undefined;
193
+ }
@@ -73,6 +73,9 @@ function addSchemaMetadata(
73
73
  if (meta["x-secret-env"]) {
74
74
  jsonField["x-secret-env"] = true;
75
75
  }
76
+ if (meta["x-templatable"]) {
77
+ jsonField["x-templatable"] = true;
78
+ }
76
79
  if (meta["x-hidden-when"]) {
77
80
  jsonField["x-hidden-when"] = meta["x-hidden-when"];
78
81
  }
@@ -0,0 +1,122 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ __resetCapabilitiesCacheForTest,
4
+ detectSandboxCapabilities,
5
+ } from "./capabilities";
6
+
7
+ describe("detectSandboxCapabilities", () => {
8
+ it("returns a deterministic, well-formed shape on the current host", () => {
9
+ __resetCapabilitiesCacheForTest();
10
+ const caps = detectSandboxCapabilities();
11
+
12
+ expect(["linux", "darwin", "other"]).toContain(caps.platform);
13
+ expect(typeof caps.euidIsRoot).toBe("boolean");
14
+ expect(typeof caps.hasPrlimit).toBe("boolean");
15
+ expect(typeof caps.rlimitNative).toBe("boolean");
16
+ expect([null, "bwrap", "nsjail", "firejail"]).toContain(caps.wrapper);
17
+ expect(typeof caps.userNamespaces).toBe("boolean");
18
+ expect(typeof caps.netNamespaces).toBe("boolean");
19
+ expect(
20
+ caps.netEgressIface === null || typeof caps.netEgressIface === "string",
21
+ ).toBe(true);
22
+ expect(typeof caps.netEgressRootless).toBe("boolean");
23
+ });
24
+
25
+ it("only claims rootless egress on a bwrap host with usable user namespaces", () => {
26
+ __resetCapabilitiesCacheForTest();
27
+ const caps = detectSandboxCapabilities();
28
+ // netEgressRootless is delivered ONLY via bwrap (the only wrapper that
29
+ // exposes the child PID for a race-free slirp4netns attach here) and needs
30
+ // unprivileged user namespaces. Never claim it otherwise.
31
+ if (caps.netEgressRootless) {
32
+ expect(caps.platform).toBe("linux");
33
+ expect(caps.wrapper).toBe("bwrap");
34
+ expect(caps.userNamespaces).toBe(true);
35
+ }
36
+ });
37
+
38
+ it("never claims rootless egress on non-linux hosts", () => {
39
+ __resetCapabilitiesCacheForTest();
40
+ const caps = detectSandboxCapabilities();
41
+ if (caps.platform !== "linux") {
42
+ expect(caps.netEgressRootless).toBe(false);
43
+ }
44
+ });
45
+
46
+ it("only claims egress plumbing on a root nsjail host with a usable iface", () => {
47
+ __resetCapabilitiesCacheForTest();
48
+ const caps = detectSandboxCapabilities();
49
+ // netEgressIface gates the functional (non-blackhole) allowlist / metadata
50
+ // block. It is delivered via nsjail macvlan and needs CAP_NET_ADMIN (euid
51
+ // root). Never claim it without nsjail + root + a netns.
52
+ if (caps.netEgressIface !== null) {
53
+ expect(caps.wrapper).toBe("nsjail");
54
+ expect(caps.euidIsRoot).toBe(true);
55
+ expect(caps.netNamespaces).toBe(true);
56
+ }
57
+ });
58
+
59
+ it("userNamespaces is the LIVE clone verdict, equal to userNsCreatable", () => {
60
+ __resetCapabilitiesCacheForTest();
61
+ const caps = detectSandboxCapabilities();
62
+ // Item 3: userNamespaces is now driven by the live clone probe, NOT the
63
+ // static sysctl toggle, so the two fields must agree. This is what closes
64
+ // the truthfulness gap (default Docker seccomp: toggle "available" but live
65
+ // clone blocked) — both report the real verdict.
66
+ expect(caps.userNamespaces).toBe(caps.userNsCreatable);
67
+ });
68
+
69
+ it("never claims a net namespace when the live userns clone fails", () => {
70
+ __resetCapabilitiesCacheForTest();
71
+ const caps = detectSandboxCapabilities();
72
+ // netNamespaces is gated on the live probe: on a host where the namespace
73
+ // cannot actually be created, we must NOT claim netNamespaces (bwrap would
74
+ // fail at spawn) — the silent gap Item 3 fixes.
75
+ if (!caps.userNsCreatable) {
76
+ expect(caps.netNamespaces).toBe(false);
77
+ expect(caps.netEgressRootless).toBe(false);
78
+ }
79
+ });
80
+
81
+ it("euidIsRoot reflects process.getuid", () => {
82
+ __resetCapabilitiesCacheForTest();
83
+ const caps = detectSandboxCapabilities();
84
+ const getuid = process.getuid?.bind(process);
85
+ const expected = getuid !== undefined && getuid() === 0;
86
+ expect(caps.euidIsRoot).toBe(expected);
87
+ });
88
+
89
+ it("caches the result across calls", () => {
90
+ __resetCapabilitiesCacheForTest();
91
+ const first = detectSandboxCapabilities();
92
+ const second = detectSandboxCapabilities();
93
+ expect(second).toBe(first); // same object reference => cached
94
+ });
95
+
96
+ it("never claims netNamespaces it cannot deliver via the chosen wrapper", () => {
97
+ __resetCapabilitiesCacheForTest();
98
+ const caps = detectSandboxCapabilities();
99
+ // netNamespaces is delivered ONLY via bwrap/nsjail here; a firejail-only or
100
+ // wrapper-less host must report false even if the kernel supports netns.
101
+ if (caps.wrapper === "firejail" || caps.wrapper === null) {
102
+ expect(caps.netNamespaces).toBe(false);
103
+ }
104
+ // And it can only be true when a delivering wrapper + userns are present.
105
+ if (caps.netNamespaces) {
106
+ expect(caps.wrapper === "bwrap" || caps.wrapper === "nsjail").toBe(true);
107
+ expect(caps.userNamespaces).toBe(true);
108
+ }
109
+ });
110
+
111
+ it("non-linux hosts report no prlimit / wrapper / namespaces", () => {
112
+ __resetCapabilitiesCacheForTest();
113
+ const caps = detectSandboxCapabilities();
114
+ if (caps.platform !== "linux") {
115
+ expect(caps.hasPrlimit).toBe(false);
116
+ expect(caps.rlimitNative).toBe(false);
117
+ expect(caps.wrapper).toBeNull();
118
+ expect(caps.userNamespaces).toBe(false);
119
+ expect(caps.netNamespaces).toBe(false);
120
+ }
121
+ });
122
+ });