@checkstack/healthcheck-script-backend 0.5.0 → 0.6.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 +118 -0
- package/package.json +7 -5
- package/src/execute-collector.test.ts +68 -0
- package/src/execute-collector.ts +38 -9
- package/src/index.ts +19 -2
- package/src/inline-script-collector.test.ts +53 -0
- package/src/inline-script-collector.ts +67 -4
- package/tsconfig.json +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,123 @@
|
|
|
1
1
|
# @checkstack/healthcheck-script-backend
|
|
2
2
|
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 270ef29: Extend in-UI script testing to health-check collectors, and add
|
|
8
|
+
load-from-run replay for automation script tests.
|
|
9
|
+
|
|
10
|
+
- Health-check collectors: a new `testCollectorScript` RPC runs the
|
|
11
|
+
inline-script (TypeScript) collector and the shell `script` collector
|
|
12
|
+
against an editable, auto-seeded sample context using the same
|
|
13
|
+
sandboxed runner the real collector uses. Surfaces beneath the
|
|
14
|
+
collector script fields in the collector editor (both marked
|
|
15
|
+
`x-script-testable`). Gated by `healthcheck.configuration.manage`.
|
|
16
|
+
- Automation replay: a new `getRunScopeForReplay` RPC reconstructs an
|
|
17
|
+
editable test context from a real run (trigger + persisted artifacts,
|
|
18
|
+
plus the durable scope snapshot when the run is still in-flight), and
|
|
19
|
+
the script-test panel gains a "Load from run" picker that seeds the
|
|
20
|
+
sample context from a past run.
|
|
21
|
+
|
|
22
|
+
Note: health-check executions do not persist the script / config /
|
|
23
|
+
check / system that produced a result, so there is no health-check
|
|
24
|
+
replay - auto-seed is the only context source for collector tests. This
|
|
25
|
+
is by design; see the feature plan.
|
|
26
|
+
|
|
27
|
+
- 270ef29: Activate npm packages in script execution: thread the managed
|
|
28
|
+
`resolutionRoot` into every user-script call site so an allowlisted package
|
|
29
|
+
can actually be `import`ed.
|
|
30
|
+
|
|
31
|
+
- `@checkstack/backend-api`: the ESM runner now always writes a per-run
|
|
32
|
+
`bunfig.toml` with `[install] auto = "disable"` and runs with that dir as
|
|
33
|
+
CWD. Without this Bun silently auto-installs any imported package from the
|
|
34
|
+
registry (verified), defeating the allowlist; with it, imports resolve
|
|
35
|
+
only against the reconciled `current/node_modules` (when a `resolutionRoot`
|
|
36
|
+
is set) and otherwise fail fast.
|
|
37
|
+
- `@checkstack/script-packages-backend`: `resolveResolutionRoot` /
|
|
38
|
+
`resolveResolutionRootFromStore` / `resolveResolutionRootForHost` decide a
|
|
39
|
+
host's resolution-root status (`none` / `ready` / `notReady`) from the
|
|
40
|
+
local `<store>/current`.
|
|
41
|
+
- `run_script` (integration-script-backend), the inline-script collector
|
|
42
|
+
(healthcheck-script-backend, core + satellite), and the in-UI `testScript`
|
|
43
|
+
/ `testCollectorScript` endpoints all resolve the root per run and pass it
|
|
44
|
+
to the runner; `run_script` surfaces a clear "npm packages not ready"
|
|
45
|
+
error when configured-but-unsynced. Shell paths are unaffected (no module
|
|
46
|
+
resolution).
|
|
47
|
+
|
|
48
|
+
An opt-in end-to-end test (`CHECKSTACK_E2E_NETWORK=1`) proves an allowlisted
|
|
49
|
+
package imports successfully through the real `run_script` action execute
|
|
50
|
+
path, with non-network degradation tests running always.
|
|
51
|
+
|
|
52
|
+
BREAKING CHANGES: `@checkstack/backend-api`'s `defaultEsmScriptRunner` now
|
|
53
|
+
always disables Bun auto-install for the user subprocess. A script that
|
|
54
|
+
previously relied on Bun silently fetching an un-vendored package from the
|
|
55
|
+
registry at import time will now fail to resolve it. This is intentional -
|
|
56
|
+
package availability is governed by the admin allowlist - but any caller
|
|
57
|
+
depending on the old implicit auto-install behavior must add the package to
|
|
58
|
+
the allowlist instead. The new `EsmScriptRunOptions.resolutionRoot` field is
|
|
59
|
+
optional and additive (defaults to today's `os.tmpdir()` behavior when
|
|
60
|
+
unset), so the runner API itself is source-compatible.
|
|
61
|
+
|
|
62
|
+
- 270ef29: Secrets platform Phase 3: just-in-time secret delivery to satellites + source-side masking, and central-execution injection for healthcheck collectors.
|
|
63
|
+
|
|
64
|
+
- New satellite WS messages `request_run_secrets` / `run_secrets`: just
|
|
65
|
+
before a satellite runs a collector that declares a `secretEnv`, it asks
|
|
66
|
+
core for that collector's resolved env; core resolves ONLY the secrets the
|
|
67
|
+
collector's OWN persisted assignment declares (least-privilege — the
|
|
68
|
+
satellite cannot choose) and replies with the env map (or a clear error).
|
|
69
|
+
The satellite injects it memory-only for the run and drops it on
|
|
70
|
+
completion. Secrets never ride the persisted assignment and never touch
|
|
71
|
+
disk.
|
|
72
|
+
- Source-side masking: the satellite runs `maskSecrets` over the collector's
|
|
73
|
+
stdout/stderr/result/error using the run's delivered values BEFORE the
|
|
74
|
+
result leaves the satellite (defense in depth).
|
|
75
|
+
- `CollectorStrategy.execute` gains an optional `secretEnv`. The
|
|
76
|
+
inline-script and shell collectors inject it into the runner
|
|
77
|
+
(`process.env` / `$VAR`) and mask the values out of their output.
|
|
78
|
+
- Healthcheck collectors running centrally (the queue executor) also resolve
|
|
79
|
+
- inject `secretEnv` via `secretResolverRef`, closing the gap where a
|
|
80
|
+
centrally-run secretEnv collector got no secrets. A missing required
|
|
81
|
+
secret fails the run clearly in all paths.
|
|
82
|
+
|
|
83
|
+
### Patch Changes
|
|
84
|
+
|
|
85
|
+
- Updated dependencies [270ef29]
|
|
86
|
+
- Updated dependencies [270ef29]
|
|
87
|
+
- Updated dependencies [270ef29]
|
|
88
|
+
- Updated dependencies [270ef29]
|
|
89
|
+
- Updated dependencies [270ef29]
|
|
90
|
+
- Updated dependencies [270ef29]
|
|
91
|
+
- Updated dependencies [b995afb]
|
|
92
|
+
- Updated dependencies [b995afb]
|
|
93
|
+
- Updated dependencies [b995afb]
|
|
94
|
+
- Updated dependencies [270ef29]
|
|
95
|
+
- Updated dependencies [270ef29]
|
|
96
|
+
- Updated dependencies [270ef29]
|
|
97
|
+
- Updated dependencies [270ef29]
|
|
98
|
+
- Updated dependencies [270ef29]
|
|
99
|
+
- Updated dependencies [b995afb]
|
|
100
|
+
- Updated dependencies [b995afb]
|
|
101
|
+
- Updated dependencies [270ef29]
|
|
102
|
+
- Updated dependencies [270ef29]
|
|
103
|
+
- Updated dependencies [270ef29]
|
|
104
|
+
- Updated dependencies [b995afb]
|
|
105
|
+
- Updated dependencies [270ef29]
|
|
106
|
+
- Updated dependencies [b995afb]
|
|
107
|
+
- Updated dependencies [270ef29]
|
|
108
|
+
- Updated dependencies [270ef29]
|
|
109
|
+
- Updated dependencies [b995afb]
|
|
110
|
+
- Updated dependencies [270ef29]
|
|
111
|
+
- Updated dependencies [270ef29]
|
|
112
|
+
- Updated dependencies [270ef29]
|
|
113
|
+
- Updated dependencies [270ef29]
|
|
114
|
+
- Updated dependencies [270ef29]
|
|
115
|
+
- Updated dependencies [b995afb]
|
|
116
|
+
- @checkstack/backend-api@0.19.0
|
|
117
|
+
- @checkstack/healthcheck-common@1.4.0
|
|
118
|
+
- @checkstack/script-packages-backend@0.2.0
|
|
119
|
+
- @checkstack/secrets-common@0.1.0
|
|
120
|
+
|
|
3
121
|
## 0.5.0
|
|
4
122
|
|
|
5
123
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/healthcheck-script-backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"checkstack": {
|
|
@@ -14,15 +14,17 @@
|
|
|
14
14
|
"pack": "bunx @checkstack/scripts plugin-pack"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@checkstack/backend-api": "0.
|
|
18
|
-
"@checkstack/common": "0.
|
|
19
|
-
"@checkstack/healthcheck-common": "1.
|
|
17
|
+
"@checkstack/backend-api": "0.18.0",
|
|
18
|
+
"@checkstack/common": "0.12.0",
|
|
19
|
+
"@checkstack/healthcheck-common": "1.3.0",
|
|
20
|
+
"@checkstack/script-packages-backend": "0.1.0",
|
|
21
|
+
"@checkstack/secrets-common": "0.0.1"
|
|
20
22
|
},
|
|
21
23
|
"devDependencies": {
|
|
22
24
|
"@types/bun": "^1.0.0",
|
|
23
25
|
"typescript": "^5.0.0",
|
|
24
26
|
"@checkstack/tsconfig": "0.0.7",
|
|
25
|
-
"@checkstack/scripts": "0.3.
|
|
27
|
+
"@checkstack/scripts": "0.3.4"
|
|
26
28
|
},
|
|
27
29
|
"description": "Checkstack healthcheck-script-backend plugin",
|
|
28
30
|
"author": {
|
|
@@ -294,3 +294,71 @@ describe("ExecuteCollector", () => {
|
|
|
294
294
|
});
|
|
295
295
|
});
|
|
296
296
|
});
|
|
297
|
+
|
|
298
|
+
describe("ExecuteCollector secret env injection + source-side masking", () => {
|
|
299
|
+
// Capture the env the collector passes to the transport client. Typed
|
|
300
|
+
// so we can assert without indexing the loosely-typed mock.calls tuple.
|
|
301
|
+
const makeRecordingClient = (
|
|
302
|
+
response: { stdout: string; stderr: string },
|
|
303
|
+
): {
|
|
304
|
+
client: ScriptTransportClient;
|
|
305
|
+
getEnv: () => Record<string, string> | undefined;
|
|
306
|
+
} => {
|
|
307
|
+
let capturedEnv: Record<string, string> | undefined;
|
|
308
|
+
return {
|
|
309
|
+
getEnv: () => capturedEnv,
|
|
310
|
+
client: {
|
|
311
|
+
exec: async (input) => {
|
|
312
|
+
capturedEnv = input.env;
|
|
313
|
+
return {
|
|
314
|
+
exitCode: 0,
|
|
315
|
+
stdout: response.stdout,
|
|
316
|
+
stderr: response.stderr,
|
|
317
|
+
timedOut: false,
|
|
318
|
+
};
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
it("injects the delivered secretEnv into the shell exec and masks it from output", async () => {
|
|
325
|
+
const { client, getEnv } = makeRecordingClient({
|
|
326
|
+
stdout: "value=sh-secret-987",
|
|
327
|
+
stderr: "warn sh-secret-987",
|
|
328
|
+
});
|
|
329
|
+
const collector = new ExecuteCollector();
|
|
330
|
+
|
|
331
|
+
const result = await collector.execute({
|
|
332
|
+
config: {
|
|
333
|
+
script: "echo value=$TOKEN",
|
|
334
|
+
timeout: 5000,
|
|
335
|
+
secretEnv: { TOKEN: "${{ secrets.tok }}" },
|
|
336
|
+
},
|
|
337
|
+
client,
|
|
338
|
+
pluginId: "test",
|
|
339
|
+
secretEnv: { TOKEN: "sh-secret-987" },
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// The exec received the injected env.
|
|
343
|
+
expect(getEnv()?.TOKEN).toBe("sh-secret-987");
|
|
344
|
+
// The secret is redacted from stdout/stderr (source-side).
|
|
345
|
+
expect(result.result.stdout).toBe("value=****");
|
|
346
|
+
expect(result.result.stderr).toBe("warn ****");
|
|
347
|
+
expect(JSON.stringify(result)).not.toContain("sh-secret-987");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("does not inject or mask when no secretEnv is delivered", async () => {
|
|
351
|
+
const { client, getEnv } = makeRecordingClient({
|
|
352
|
+
stdout: "no secrets",
|
|
353
|
+
stderr: "",
|
|
354
|
+
});
|
|
355
|
+
const collector = new ExecuteCollector();
|
|
356
|
+
const result = await collector.execute({
|
|
357
|
+
config: { script: "echo hi", timeout: 5000 },
|
|
358
|
+
client,
|
|
359
|
+
pluginId: "test",
|
|
360
|
+
});
|
|
361
|
+
expect(getEnv()?.TOKEN).toBeUndefined();
|
|
362
|
+
expect(result.result.stdout).toBe("no secrets");
|
|
363
|
+
});
|
|
364
|
+
});
|
package/src/execute-collector.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
Versioned,
|
|
3
3
|
z,
|
|
4
4
|
configString,
|
|
5
|
+
withConfigMeta,
|
|
5
6
|
type HealthCheckRunForAggregation,
|
|
6
7
|
type CollectorResult,
|
|
7
8
|
type CollectorStrategy,
|
|
@@ -21,6 +22,10 @@ import {
|
|
|
21
22
|
} from "@checkstack/healthcheck-common";
|
|
22
23
|
import { pluginMetadata } from "./plugin-metadata";
|
|
23
24
|
import type { ScriptTransportClient } from "./transport-client";
|
|
25
|
+
import {
|
|
26
|
+
secretEnvMappingSchema,
|
|
27
|
+
maskScriptRunOutput,
|
|
28
|
+
} from "@checkstack/secrets-common";
|
|
24
29
|
|
|
25
30
|
// ============================================================================
|
|
26
31
|
// RUN-CONTEXT ENV INJECTION
|
|
@@ -82,6 +87,7 @@ function shellQuote(arg: string): string {
|
|
|
82
87
|
const executeConfigSchemaV2 = z.object({
|
|
83
88
|
script: configString({
|
|
84
89
|
"x-editor-types": ["shell"],
|
|
90
|
+
"x-script-testable": true,
|
|
85
91
|
}).describe(
|
|
86
92
|
"Shell script source. Executed via `sh -c`, so pipes, redirects, `if`/`for`/`while`, variable expansion, command substitution etc. all work. Exit non-zero to fail the check.",
|
|
87
93
|
),
|
|
@@ -95,6 +101,11 @@ const executeConfigSchemaV2 = z.object({
|
|
|
95
101
|
.describe(
|
|
96
102
|
"Extra environment variables to expose to the script. Merged on top of the safe-vars whitelist (PATH, HOME, ...).",
|
|
97
103
|
),
|
|
104
|
+
secretEnv: withConfigMeta(secretEnvMappingSchema, { "x-secret-env": true })
|
|
105
|
+
.optional()
|
|
106
|
+
.describe(
|
|
107
|
+
'Secret → env mapping, e.g. { "API_TOKEN": "${{ secrets.token }}" }. Only the named secrets are resolved and injected for this run (read via process.env.API_TOKEN / $API_TOKEN); on a satellite they are delivered just-in-time over the encrypted channel, never persisted. Values are masked out of the collector output.',
|
|
108
|
+
),
|
|
98
109
|
timeout: z
|
|
99
110
|
.number()
|
|
100
111
|
.int()
|
|
@@ -243,20 +254,25 @@ export class ExecuteCollector implements CollectorStrategy<
|
|
|
243
254
|
config,
|
|
244
255
|
client,
|
|
245
256
|
runContext,
|
|
257
|
+
secretEnv,
|
|
246
258
|
}: {
|
|
247
259
|
config: ExecuteConfig;
|
|
248
260
|
client: ScriptTransportClient;
|
|
249
261
|
pluginId: string;
|
|
250
262
|
runContext?: CollectorRunContext;
|
|
263
|
+
secretEnv?: Record<string, string>;
|
|
251
264
|
}): Promise<CollectorResult<ExecuteResult>> {
|
|
252
265
|
const startTime = Date.now();
|
|
253
266
|
|
|
254
|
-
// Merge run-context metadata under the user-supplied env
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
:
|
|
267
|
+
// Merge run-context metadata under the user-supplied env, then the
|
|
268
|
+
// resolved secret env on top (a declared secret env name wins, matching
|
|
269
|
+
// the central action's layering). User `config.env` still wins over the
|
|
270
|
+
// run-context vars.
|
|
271
|
+
const env = {
|
|
272
|
+
...(runContext ? runContextEnv(runContext) : {}),
|
|
273
|
+
...config.env,
|
|
274
|
+
...secretEnv,
|
|
275
|
+
};
|
|
260
276
|
|
|
261
277
|
const response = await client.exec({
|
|
262
278
|
script: config.script,
|
|
@@ -265,20 +281,33 @@ export class ExecuteCollector implements CollectorStrategy<
|
|
|
265
281
|
timeout: config.timeout,
|
|
266
282
|
});
|
|
267
283
|
|
|
284
|
+
// Source-side masking: redact the delivered secret values from the
|
|
285
|
+
// captured output BEFORE it leaves the satellite (core masks again on
|
|
286
|
+
// receipt). A script echoing a secret it was given is masked here.
|
|
287
|
+
const maskValues = Object.values(secretEnv ?? {});
|
|
288
|
+
const masked = maskScriptRunOutput({
|
|
289
|
+
output: {
|
|
290
|
+
stdout: response.stdout,
|
|
291
|
+
stderr: response.stderr,
|
|
292
|
+
error: response.error,
|
|
293
|
+
},
|
|
294
|
+
values: maskValues,
|
|
295
|
+
});
|
|
296
|
+
|
|
268
297
|
const executionTimeMs = Date.now() - startTime;
|
|
269
298
|
const success = response.exitCode === 0 && !response.timedOut;
|
|
270
299
|
|
|
271
300
|
return {
|
|
272
301
|
result: {
|
|
273
302
|
exitCode: response.exitCode,
|
|
274
|
-
stdout:
|
|
275
|
-
stderr:
|
|
303
|
+
stdout: masked.stdout,
|
|
304
|
+
stderr: masked.stderr,
|
|
276
305
|
executionTimeMs,
|
|
277
306
|
success,
|
|
278
307
|
timedOut: response.timedOut,
|
|
279
308
|
},
|
|
280
309
|
error:
|
|
281
|
-
|
|
310
|
+
masked.error ??
|
|
282
311
|
(success ? undefined : `Exit code: ${response.exitCode}`),
|
|
283
312
|
};
|
|
284
313
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
|
|
2
|
+
import {
|
|
3
|
+
resolveResolutionRootFromStore,
|
|
4
|
+
resolveScriptPackagesDir,
|
|
5
|
+
} from "@checkstack/script-packages-backend";
|
|
2
6
|
import { ScriptHealthCheckStrategy } from "./strategy";
|
|
3
7
|
import { pluginMetadata } from "./plugin-metadata";
|
|
4
8
|
import { ExecuteCollector } from "./execute-collector";
|
|
5
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
InlineScriptCollector,
|
|
11
|
+
defaultInlineScriptExecutor,
|
|
12
|
+
} from "./inline-script-collector";
|
|
6
13
|
|
|
7
14
|
export default createBackendPlugin({
|
|
8
15
|
metadata: pluginMetadata,
|
|
@@ -18,7 +25,17 @@ export default createBackendPlugin({
|
|
|
18
25
|
const strategy = new ScriptHealthCheckStrategy();
|
|
19
26
|
healthCheckRegistry.register(strategy);
|
|
20
27
|
collectorRegistry.register(new ExecuteCollector());
|
|
21
|
-
|
|
28
|
+
// The inline TS collector resolves the managed npm-package tree from
|
|
29
|
+
// the local store - identical on core and satellites (same
|
|
30
|
+
// `<dataDir>/script-packages/current` convention). No RPC needed, so
|
|
31
|
+
// it works in the satellite runtime too. Execution safety is
|
|
32
|
+
// guaranteed by the runner (auto-install disabled); this just points
|
|
33
|
+
// it at the synced tree when present.
|
|
34
|
+
collectorRegistry.register(
|
|
35
|
+
new InlineScriptCollector(defaultInlineScriptExecutor, () =>
|
|
36
|
+
resolveResolutionRootFromStore(resolveScriptPackagesDir()),
|
|
37
|
+
),
|
|
38
|
+
);
|
|
22
39
|
},
|
|
23
40
|
});
|
|
24
41
|
},
|
|
@@ -2,6 +2,7 @@ import { describe, it, expect } from "bun:test";
|
|
|
2
2
|
import {
|
|
3
3
|
InlineScriptCollector,
|
|
4
4
|
type InlineScriptConfig,
|
|
5
|
+
type InlineScriptExecutor,
|
|
5
6
|
} from "./inline-script-collector";
|
|
6
7
|
import type { ScriptTransportClient } from "./transport-client";
|
|
7
8
|
|
|
@@ -404,3 +405,55 @@ describe("InlineScriptCollector", () => {
|
|
|
404
405
|
});
|
|
405
406
|
});
|
|
406
407
|
});
|
|
408
|
+
|
|
409
|
+
describe("InlineScriptCollector secret env injection + source-side masking", () => {
|
|
410
|
+
it("injects the delivered secretEnv into the executor and masks it from output", async () => {
|
|
411
|
+
const calls: Array<{ secretEnv?: Record<string, string> }> = [];
|
|
412
|
+
const mockExecutor: InlineScriptExecutor = {
|
|
413
|
+
execute: async (input) => {
|
|
414
|
+
calls.push({ secretEnv: input.secretEnv });
|
|
415
|
+
// Simulate a script that echoes its injected secret.
|
|
416
|
+
return {
|
|
417
|
+
result: { success: true, message: "token is gh_realSecret123" },
|
|
418
|
+
stdout: "logged gh_realSecret123",
|
|
419
|
+
stderr: "",
|
|
420
|
+
timedOut: false,
|
|
421
|
+
};
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
const collector = new InlineScriptCollector(mockExecutor);
|
|
425
|
+
|
|
426
|
+
const result = await collector.execute({
|
|
427
|
+
config: createConfig({
|
|
428
|
+
secretEnv: { API_TOKEN: "${{ secrets.tok }}" },
|
|
429
|
+
}),
|
|
430
|
+
client: mockClient,
|
|
431
|
+
pluginId: "script",
|
|
432
|
+
secretEnv: { API_TOKEN: "gh_realSecret123" },
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// The executor received the injected env.
|
|
436
|
+
expect(calls[0]?.secretEnv).toEqual({ API_TOKEN: "gh_realSecret123" });
|
|
437
|
+
// The secret is redacted from the result message (source-side).
|
|
438
|
+
expect(result.result.message).toBe("token is ****");
|
|
439
|
+
expect(JSON.stringify(result)).not.toContain("gh_realSecret123");
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("does not mask anything when no secretEnv is delivered", async () => {
|
|
443
|
+
const mockExecutor: InlineScriptExecutor = {
|
|
444
|
+
execute: async () => ({
|
|
445
|
+
result: { success: true, message: "nothing secret" },
|
|
446
|
+
stdout: "plain output",
|
|
447
|
+
stderr: "",
|
|
448
|
+
timedOut: false,
|
|
449
|
+
}),
|
|
450
|
+
};
|
|
451
|
+
const collector = new InlineScriptCollector(mockExecutor);
|
|
452
|
+
const result = await collector.execute({
|
|
453
|
+
config: createConfig(),
|
|
454
|
+
client: mockClient,
|
|
455
|
+
pluginId: "script",
|
|
456
|
+
});
|
|
457
|
+
expect(result.result.message).toBe("nothing secret");
|
|
458
|
+
});
|
|
459
|
+
});
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
Versioned,
|
|
3
3
|
z,
|
|
4
4
|
configString,
|
|
5
|
+
withConfigMeta,
|
|
5
6
|
requestTimeoutMs,
|
|
6
7
|
defaultEsmScriptRunner,
|
|
7
8
|
type EsmScriptRunResult,
|
|
@@ -25,6 +26,11 @@ import {
|
|
|
25
26
|
import { pluginMetadata } from "./plugin-metadata";
|
|
26
27
|
import type { ScriptTransportClient } from "./transport-client";
|
|
27
28
|
import { extractErrorMessage } from "@checkstack/common";
|
|
29
|
+
import {
|
|
30
|
+
secretEnvMappingSchema,
|
|
31
|
+
maskScriptRunOutput,
|
|
32
|
+
} from "@checkstack/secrets-common";
|
|
33
|
+
import type { ResolutionRootStatus } from "@checkstack/script-packages-backend";
|
|
28
34
|
|
|
29
35
|
// ============================================================================
|
|
30
36
|
// EXECUTOR ADAPTER
|
|
@@ -58,6 +64,10 @@ export interface InlineScriptExecutor {
|
|
|
58
64
|
config: Record<string, unknown>;
|
|
59
65
|
timeoutMs: number;
|
|
60
66
|
runContext?: CollectorRunContext;
|
|
67
|
+
/** Managed npm-package resolution root for this run, if ready. */
|
|
68
|
+
resolutionRoot?: string;
|
|
69
|
+
/** Resolved secret env injected into the runner for this run only. */
|
|
70
|
+
secretEnv?: Record<string, string>;
|
|
61
71
|
}): Promise<InlineScriptExecutionResult>;
|
|
62
72
|
}
|
|
63
73
|
|
|
@@ -67,8 +77,15 @@ export interface InlineScriptExecutor {
|
|
|
67
77
|
* health-check runtime surface) and the virtual `@checkstack/healthcheck`
|
|
68
78
|
* module / global `defineHealthCheck` helper.
|
|
69
79
|
*/
|
|
70
|
-
const defaultInlineScriptExecutor: InlineScriptExecutor = {
|
|
71
|
-
async execute({
|
|
80
|
+
export const defaultInlineScriptExecutor: InlineScriptExecutor = {
|
|
81
|
+
async execute({
|
|
82
|
+
script,
|
|
83
|
+
config,
|
|
84
|
+
timeoutMs,
|
|
85
|
+
runContext,
|
|
86
|
+
resolutionRoot,
|
|
87
|
+
secretEnv,
|
|
88
|
+
}) {
|
|
72
89
|
const res: EsmScriptRunResult = await defaultEsmScriptRunner.run({
|
|
73
90
|
script,
|
|
74
91
|
context: {
|
|
@@ -80,6 +97,11 @@ const defaultInlineScriptExecutor: InlineScriptExecutor = {
|
|
|
80
97
|
timeoutMs,
|
|
81
98
|
helperModuleName: "@checkstack/healthcheck",
|
|
82
99
|
helperFunctionName: "defineHealthCheck",
|
|
100
|
+
...(resolutionRoot ? { resolutionRoot } : {}),
|
|
101
|
+
// Inject the resolved secrets as process.env for THIS run only.
|
|
102
|
+
...(secretEnv && Object.keys(secretEnv).length > 0
|
|
103
|
+
? { env: secretEnv }
|
|
104
|
+
: {}),
|
|
83
105
|
});
|
|
84
106
|
return res;
|
|
85
107
|
},
|
|
@@ -92,9 +114,15 @@ const defaultInlineScriptExecutor: InlineScriptExecutor = {
|
|
|
92
114
|
const inlineScriptConfigSchema = z.object({
|
|
93
115
|
script: configString({
|
|
94
116
|
"x-editor-types": ["typescript"],
|
|
117
|
+
"x-script-testable": true,
|
|
95
118
|
}).describe(
|
|
96
119
|
"TypeScript/JavaScript module. Use `import { ... } from \"node:os\"` to pull in Node built-ins. The recommended pattern is `export default defineHealthCheck({ success, message?, value? })` — `defineHealthCheck` is provided by `@checkstack/healthcheck` and asserts the return shape at the type level. Throwing also signals failure.",
|
|
97
120
|
),
|
|
121
|
+
secretEnv: withConfigMeta(secretEnvMappingSchema, { "x-secret-env": true })
|
|
122
|
+
.optional()
|
|
123
|
+
.describe(
|
|
124
|
+
'Secret → env mapping, e.g. { "API_TOKEN": "${{ secrets.token }}" }. Only the named secrets are resolved and injected for this run (read via process.env.API_TOKEN / $API_TOKEN); on a satellite they are delivered just-in-time over the encrypted channel, never persisted. Values are masked out of the collector output.',
|
|
125
|
+
),
|
|
98
126
|
timeout: requestTimeoutMs().describe("Maximum execution time in milliseconds"),
|
|
99
127
|
});
|
|
100
128
|
|
|
@@ -254,30 +282,65 @@ export class InlineScriptCollector implements CollectorStrategy<
|
|
|
254
282
|
});
|
|
255
283
|
|
|
256
284
|
private executor: InlineScriptExecutor;
|
|
285
|
+
private getResolutionRoot?: () => Promise<ResolutionRootStatus>;
|
|
257
286
|
|
|
258
|
-
constructor(
|
|
287
|
+
constructor(
|
|
288
|
+
executor: InlineScriptExecutor = defaultInlineScriptExecutor,
|
|
289
|
+
getResolutionRoot?: () => Promise<ResolutionRootStatus>,
|
|
290
|
+
) {
|
|
259
291
|
this.executor = executor;
|
|
292
|
+
this.getResolutionRoot = getResolutionRoot;
|
|
260
293
|
}
|
|
261
294
|
|
|
262
295
|
async execute({
|
|
263
296
|
config,
|
|
264
297
|
runContext,
|
|
298
|
+
secretEnv,
|
|
265
299
|
}: {
|
|
266
300
|
config: InlineScriptConfig;
|
|
267
301
|
client: ScriptTransportClient;
|
|
268
302
|
pluginId: string;
|
|
269
303
|
runContext?: CollectorRunContext;
|
|
304
|
+
secretEnv?: Record<string, string>;
|
|
270
305
|
}): Promise<CollectorResult<InlineScriptResult>> {
|
|
271
306
|
const startTime = Date.now();
|
|
272
307
|
|
|
308
|
+
// Resolve the managed npm-package root. `notReady` -> fail clearly;
|
|
309
|
+
// `none` -> unset (no packages); `ready` -> point the runner at it.
|
|
310
|
+
const rootStatus = await this.getResolutionRoot?.();
|
|
311
|
+
if (rootStatus?.mode === "notReady") {
|
|
312
|
+
return {
|
|
313
|
+
result: {
|
|
314
|
+
success: false,
|
|
315
|
+
message: rootStatus.reason,
|
|
316
|
+
executionTimeMs: Date.now() - startTime,
|
|
317
|
+
timedOut: false,
|
|
318
|
+
},
|
|
319
|
+
error: rootStatus.reason,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
const resolutionRoot =
|
|
323
|
+
rootStatus?.mode === "ready" ? rootStatus.root : undefined;
|
|
324
|
+
|
|
325
|
+
// Source-side masking values: the run's delivered secret values.
|
|
326
|
+
const maskValues = Object.values(secretEnv ?? {});
|
|
327
|
+
|
|
273
328
|
let exec: InlineScriptExecutionResult;
|
|
274
329
|
try {
|
|
275
|
-
|
|
330
|
+
const raw = await this.executor.execute({
|
|
276
331
|
script: config.script,
|
|
277
332
|
config: config as unknown as Record<string, unknown>,
|
|
278
333
|
timeoutMs: config.timeout,
|
|
279
334
|
runContext,
|
|
335
|
+
...(resolutionRoot ? { resolutionRoot } : {}),
|
|
336
|
+
...(secretEnv ? { secretEnv } : {}),
|
|
280
337
|
});
|
|
338
|
+
// Redact the delivered secret values from the captured output BEFORE
|
|
339
|
+
// any of it leaves the satellite (defense in depth: core masks again
|
|
340
|
+
// on receipt). A script echoing a secret it was given is masked here.
|
|
341
|
+
// `raw` carries `stdout`/`stderr` (required by ScriptRunOutput) plus
|
|
342
|
+
// `timedOut`, so the masked result keeps the full shape.
|
|
343
|
+
exec = maskScriptRunOutput({ output: raw, values: maskValues });
|
|
281
344
|
} catch (error) {
|
|
282
345
|
const executionTimeMs = Date.now() - startTime;
|
|
283
346
|
const message = extractErrorMessage(error);
|