@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
|
@@ -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
|
+
}
|
package/src/schema-utils.ts
CHANGED
|
@@ -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
|
+
});
|