@checkstack/script-packages-backend 0.2.0 → 0.3.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 +100 -0
- package/drizzle/0002_dry_sue_storm.sql +27 -0
- package/drizzle/meta/0002_snapshot.json +666 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +9 -6
- package/src/audit-delta.test.ts +127 -0
- package/src/audit-delta.ts +100 -0
- package/src/audit-parse.test.ts +128 -0
- package/src/audit-parse.ts +147 -0
- package/src/audit-runner.test.ts +230 -0
- package/src/audit-runner.ts +224 -0
- package/src/audit-scanner.test.ts +101 -0
- package/src/audit-scanner.ts +156 -0
- package/src/hooks.ts +14 -0
- package/src/index.ts +264 -3
- package/src/router.ts +49 -0
- package/src/sandbox-policy-router.test.ts +105 -0
- package/src/sandbox-policy.test.ts +119 -0
- package/src/sandbox-policy.ts +68 -0
- package/src/sandbox-startup-log.test.ts +128 -0
- package/src/sandbox-startup-log.ts +83 -0
- package/src/schema.ts +53 -1
- package/src/sdk-types-route.test.ts +121 -0
- package/src/sdk-types-route.ts +137 -0
- package/src/stores.ts +216 -1
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { spawn } from "bun";
|
|
2
|
+
import { mkdir, writeFile, rm } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type {
|
|
5
|
+
AuditAdvisory,
|
|
6
|
+
PackageSpec,
|
|
7
|
+
} from "@checkstack/script-packages-common";
|
|
8
|
+
import { buildDependencies, buildStorePackageJson } from "./lockfile";
|
|
9
|
+
import { renderNpmrc, type NpmrcInput } from "./npmrc";
|
|
10
|
+
import { parseBunAudit } from "./audit-parse";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Minimal subset of Bun's `spawn` the scanner relies on. Injectable so tests
|
|
14
|
+
* can assert on the spawn options (e.g. `BUN_INSTALL_CACHE_DIR`) without
|
|
15
|
+
* launching a real process.
|
|
16
|
+
*/
|
|
17
|
+
export interface SpawnHandle {
|
|
18
|
+
// The scanner always spawns with `stdout`/`stderr: "pipe"`, so both are
|
|
19
|
+
// readable streams (never fds).
|
|
20
|
+
stdout: ReadableStream<Uint8Array>;
|
|
21
|
+
stderr: ReadableStream<Uint8Array>;
|
|
22
|
+
exited: Promise<number>;
|
|
23
|
+
}
|
|
24
|
+
export interface SpawnOptions {
|
|
25
|
+
cmd: string[];
|
|
26
|
+
cwd: string;
|
|
27
|
+
env: Record<string, string | undefined>;
|
|
28
|
+
stdout: "pipe";
|
|
29
|
+
stderr: "pipe";
|
|
30
|
+
}
|
|
31
|
+
export type SpawnFn = (options: SpawnOptions) => SpawnHandle;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Runs `bun audit --json` against the resolved script-packages tree, reusing
|
|
35
|
+
* the EXACT scratch / `.npmrc` / registry-config setup the installer's
|
|
36
|
+
* {@link createCentralResolver} uses (same `package.json`, same token-bearing
|
|
37
|
+
* `.npmrc`, same throwaway-scratch cleanup) so audit and install never drift
|
|
38
|
+
* on registry/auth config. The audit reports purely from the lockfile against
|
|
39
|
+
* the advisory DB - no install scripts run.
|
|
40
|
+
*
|
|
41
|
+
* The token-bearing scratch dir is removed on EVERY exit path (success OR
|
|
42
|
+
* throw) so the plaintext registry token never lingers on disk.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
export interface AuditScannerOptions {
|
|
46
|
+
/** Scratch dir for the audit (created + removed per scan). */
|
|
47
|
+
scratchDir: string;
|
|
48
|
+
/**
|
|
49
|
+
* Dedicated Bun cache dir to install into - MUST be the same
|
|
50
|
+
* `BUN_INSTALL_CACHE_DIR` the installer's resolver uses (`paths.cache`), so
|
|
51
|
+
* the audit reuses already-fetched packages instead of re-downloading into
|
|
52
|
+
* Bun's default global cache and drifting from the install path.
|
|
53
|
+
*/
|
|
54
|
+
cacheDir: string;
|
|
55
|
+
/** Registry config (token already resolved from the secret store). */
|
|
56
|
+
registry: NpmrcInput;
|
|
57
|
+
/** Injectable spawn (defaults to Bun's `spawn`); for tests. */
|
|
58
|
+
spawnFn?: SpawnFn;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface AuditScanResult {
|
|
62
|
+
advisories: AuditAdvisory[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function bunfigDisableAutoInstall(): string {
|
|
66
|
+
return '[install]\nauto = "disable"\n';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface AuditScanner {
|
|
70
|
+
scan(input: {
|
|
71
|
+
packages: PackageSpec[];
|
|
72
|
+
ignoreScripts: boolean;
|
|
73
|
+
}): Promise<AuditScanResult>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createAuditScanner(options: AuditScannerOptions): AuditScanner {
|
|
77
|
+
return {
|
|
78
|
+
async scan({ packages, ignoreScripts }) {
|
|
79
|
+
const { scratchDir, cacheDir, registry } = options;
|
|
80
|
+
const spawnFn: SpawnFn = options.spawnFn ?? ((o) => spawn(o));
|
|
81
|
+
|
|
82
|
+
// No enabled packages → nothing to audit.
|
|
83
|
+
if (Object.keys(buildDependencies(packages)).length === 0) {
|
|
84
|
+
return { advisories: [] };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
await rm(scratchDir, { recursive: true, force: true });
|
|
88
|
+
await mkdir(scratchDir, { recursive: true });
|
|
89
|
+
await mkdir(cacheDir, { recursive: true });
|
|
90
|
+
|
|
91
|
+
await writeFile(
|
|
92
|
+
path.join(scratchDir, "package.json"),
|
|
93
|
+
buildStorePackageJson(packages),
|
|
94
|
+
);
|
|
95
|
+
await writeFile(path.join(scratchDir, ".npmrc"), renderNpmrc(registry));
|
|
96
|
+
await writeFile(
|
|
97
|
+
path.join(scratchDir, "bunfig.toml"),
|
|
98
|
+
bunfigDisableAutoInstall(),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
// Install (writes bun.lock from the fully-pinned set) so audit has a
|
|
103
|
+
// lockfile to report against. `--ignore-scripts` keeps the
|
|
104
|
+
// supply-chain guardrail: audit reads the lockfile + advisory DB, it
|
|
105
|
+
// does not execute package code.
|
|
106
|
+
const installArgs = ["install"];
|
|
107
|
+
if (ignoreScripts) installArgs.push("--ignore-scripts");
|
|
108
|
+
const install = spawnFn({
|
|
109
|
+
cmd: [process.execPath, ...installArgs],
|
|
110
|
+
cwd: scratchDir,
|
|
111
|
+
env: { ...process.env, BUN_INSTALL_CACHE_DIR: cacheDir },
|
|
112
|
+
stdout: "pipe",
|
|
113
|
+
stderr: "pipe",
|
|
114
|
+
});
|
|
115
|
+
const [installStderr, installExit] = await Promise.all([
|
|
116
|
+
new Response(install.stderr).text(),
|
|
117
|
+
install.exited,
|
|
118
|
+
]);
|
|
119
|
+
if (installExit !== 0) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`bun install (for audit) failed (exit ${installExit}): ${installStderr.slice(0, 800)}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// `bun audit --json` exit code is unreliable (non-zero even on a
|
|
126
|
+
// clean tree in some Bun versions), so we IGNORE it and parse stdout.
|
|
127
|
+
const audit = spawnFn({
|
|
128
|
+
cmd: [process.execPath, "audit", "--json"],
|
|
129
|
+
cwd: scratchDir,
|
|
130
|
+
env: { ...process.env, BUN_INSTALL_CACHE_DIR: cacheDir },
|
|
131
|
+
stdout: "pipe",
|
|
132
|
+
stderr: "pipe",
|
|
133
|
+
});
|
|
134
|
+
const [stdout, stderr] = await Promise.all([
|
|
135
|
+
new Response(audit.stdout).text(),
|
|
136
|
+
new Response(audit.stderr).text(),
|
|
137
|
+
audit.exited,
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
return parseBunAudit(stdout);
|
|
142
|
+
} catch (parseError) {
|
|
143
|
+
// Surface Bun's own stderr (never the .npmrc / token) to aid
|
|
144
|
+
// diagnosis when the output wasn't parseable JSON.
|
|
145
|
+
throw new Error(
|
|
146
|
+
`Could not parse bun audit output: ${(parseError as Error).message}${
|
|
147
|
+
stderr ? ` (stderr: ${stderr.slice(0, 400)})` : ""
|
|
148
|
+
}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
} finally {
|
|
152
|
+
await rm(scratchDir, { recursive: true, force: true }).catch(() => {});
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
package/src/hooks.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createHook } from "@checkstack/backend-api";
|
|
2
2
|
import {
|
|
3
3
|
SCRIPT_PACKAGES_CHANGED_HOOK_ID,
|
|
4
|
+
SCRIPT_SANDBOX_POLICY_CHANGED_HOOK_ID,
|
|
4
5
|
type ScriptPackagesChangedPayload,
|
|
6
|
+
type SandboxPolicyChangedPayload,
|
|
5
7
|
} from "@checkstack/script-packages-common";
|
|
6
8
|
|
|
7
9
|
/**
|
|
@@ -18,3 +20,15 @@ import {
|
|
|
18
20
|
export const scriptPackagesChangedHook = createHook<ScriptPackagesChangedPayload>(
|
|
19
21
|
SCRIPT_PACKAGES_CHANGED_HOOK_ID,
|
|
20
22
|
);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Backend hook fired after a successful global sandbox-policy write.
|
|
26
|
+
*
|
|
27
|
+
* Core instances subscribe in `broadcast` mode (every pod receives it) and each
|
|
28
|
+
* pushes the new policy to its OWN connected satellites (push-on-change relay).
|
|
29
|
+
* Best-effort liveness; the durable policy row is the source of truth and a
|
|
30
|
+
* satellite re-pulls the relayed policy on (re)connect.
|
|
31
|
+
*/
|
|
32
|
+
export const sandboxPolicyChangedHook = createHook<SandboxPolicyChangedPayload>(
|
|
33
|
+
SCRIPT_SANDBOX_POLICY_CHANGED_HOOK_ID,
|
|
34
|
+
);
|
package/src/index.ts
CHANGED
|
@@ -8,10 +8,16 @@ import {
|
|
|
8
8
|
} from "./registry-token";
|
|
9
9
|
import {
|
|
10
10
|
pluginMetadata,
|
|
11
|
+
scriptPackagesAccess,
|
|
11
12
|
scriptPackagesAccessRules,
|
|
12
13
|
scriptPackagesContract,
|
|
14
|
+
SCRIPT_PACKAGES_AUDIT_COMPLETED_SIGNAL,
|
|
15
|
+
type AuditRunSummary,
|
|
13
16
|
type BlobGcSummary,
|
|
14
17
|
} from "@checkstack/script-packages-common";
|
|
18
|
+
import type { SandboxPolicy } from "@checkstack/common";
|
|
19
|
+
import { AuthApi } from "@checkstack/auth-common";
|
|
20
|
+
import { NotificationApi } from "@checkstack/notification-common";
|
|
15
21
|
import type { PluginMetadata } from "@checkstack/common";
|
|
16
22
|
import { extractErrorMessage } from "@checkstack/common";
|
|
17
23
|
import { blobStoreExtensionPoint, type BlobStore } from "./blob-store";
|
|
@@ -28,8 +34,11 @@ import {
|
|
|
28
34
|
createBlobIndexStore,
|
|
29
35
|
createBlobGcStateStore,
|
|
30
36
|
createLockfileHistoryStore,
|
|
37
|
+
createAuditStore,
|
|
31
38
|
} from "./stores";
|
|
32
39
|
import { createBlobGcTrigger } from "./blob-gc-runner";
|
|
40
|
+
import { createAuditRunner } from "./audit-runner";
|
|
41
|
+
import { createAuditScanner } from "./audit-scanner";
|
|
33
42
|
import {
|
|
34
43
|
createInstallStateStore,
|
|
35
44
|
createInstallerLock,
|
|
@@ -43,10 +52,23 @@ import { createCentralResolver } from "./resolver";
|
|
|
43
52
|
import { resolveRegistryRequestConfig } from "./registry-request-config";
|
|
44
53
|
import { createReconcileFsDeps } from "./reconcile-fs";
|
|
45
54
|
import { reconcileToHash } from "./reconciler";
|
|
46
|
-
import { scriptPackagesChangedHook } from "./hooks";
|
|
55
|
+
import { scriptPackagesChangedHook, sandboxPolicyChangedHook } from "./hooks";
|
|
56
|
+
import {
|
|
57
|
+
createSandboxPolicyService,
|
|
58
|
+
registerScriptPackagesSandboxProvider,
|
|
59
|
+
} from "./sandbox-policy";
|
|
60
|
+
import { logSandboxStartupReadiness } from "./sandbox-startup-log";
|
|
47
61
|
import { createScriptPackagesRouter } from "./router";
|
|
48
62
|
import { createTypeClosureHttpHandler } from "./type-acquisition-route";
|
|
49
|
-
import {
|
|
63
|
+
import { createSdkTypesHttpHandler } from "./sdk-types-route";
|
|
64
|
+
import {
|
|
65
|
+
TYPE_ACQUISITION_PATH_PREFIX,
|
|
66
|
+
SDK_TYPES_PATH_PREFIX,
|
|
67
|
+
} from "@checkstack/script-packages-common";
|
|
68
|
+
import {
|
|
69
|
+
SDK_EDITOR_BUNDLE_DTS,
|
|
70
|
+
SDK_RELEASE_VERSION,
|
|
71
|
+
} from "@checkstack/sdk/editor-bundle";
|
|
50
72
|
import * as schema from "./schema";
|
|
51
73
|
|
|
52
74
|
interface EnvStash {
|
|
@@ -57,6 +79,14 @@ interface EnvStash {
|
|
|
57
79
|
* Undefined until `afterPluginsReady` runs.
|
|
58
80
|
*/
|
|
59
81
|
emitChanged?: (lockfileHash: string) => Promise<void>;
|
|
82
|
+
/**
|
|
83
|
+
* Set in `afterPluginsReady` (where `emitHook` exists) and called by the
|
|
84
|
+
* `setSandboxPolicy` handler (wired in `init`) after a successful policy
|
|
85
|
+
* write, so core instances broadcast the new policy to their satellites.
|
|
86
|
+
* Undefined until `afterPluginsReady` runs (a write before then still
|
|
87
|
+
* persists durably; satellites pick it up on next connect).
|
|
88
|
+
*/
|
|
89
|
+
emitSandboxPolicyChanged?: (policy: SandboxPolicy) => Promise<void>;
|
|
60
90
|
/** Registry token store (internal secrets), set in `init`. */
|
|
61
91
|
registryToken?: RegistryTokenStore;
|
|
62
92
|
/**
|
|
@@ -64,6 +94,12 @@ interface EnvStash {
|
|
|
64
94
|
* Reused by the scheduled recurring job registered in `afterPluginsReady`.
|
|
65
95
|
*/
|
|
66
96
|
triggerBlobGc?: () => Promise<BlobGcSummary>;
|
|
97
|
+
/**
|
|
98
|
+
* Vulnerability-audit trigger built in `init` (wires the scanner, stores,
|
|
99
|
+
* installer lock, and notification path). Reused by the scheduled recurring
|
|
100
|
+
* job registered in `afterPluginsReady`.
|
|
101
|
+
*/
|
|
102
|
+
triggerAudit?: () => Promise<AuditRunSummary>;
|
|
67
103
|
}
|
|
68
104
|
|
|
69
105
|
export default createBackendPlugin({
|
|
@@ -86,17 +122,23 @@ export default createBackendPlugin({
|
|
|
86
122
|
deps: {
|
|
87
123
|
logger: coreServices.logger,
|
|
88
124
|
rpc: coreServices.rpc,
|
|
125
|
+
rpcClient: coreServices.rpcClient,
|
|
126
|
+
signalService: coreServices.signalService,
|
|
89
127
|
auth: coreServices.auth,
|
|
90
128
|
advisoryLock: coreServices.advisoryLock,
|
|
91
129
|
queueManager: coreServices.queueManager,
|
|
130
|
+
config: coreServices.config,
|
|
92
131
|
internalSecrets: internalSecretsRef,
|
|
93
132
|
},
|
|
94
133
|
init: async ({
|
|
95
134
|
logger,
|
|
96
135
|
database,
|
|
97
136
|
rpc,
|
|
137
|
+
rpcClient,
|
|
138
|
+
signalService,
|
|
98
139
|
auth,
|
|
99
140
|
advisoryLock,
|
|
141
|
+
config,
|
|
100
142
|
internalSecrets,
|
|
101
143
|
}) => {
|
|
102
144
|
logger.debug("📦 Initializing Script Packages Backend...");
|
|
@@ -148,6 +190,89 @@ export default createBackendPlugin({
|
|
|
148
190
|
});
|
|
149
191
|
(env as unknown as EnvStash).triggerBlobGc = triggerBlobGc;
|
|
150
192
|
|
|
193
|
+
// Vulnerability-audit trigger: shared by the admin `auditNow` RPC and
|
|
194
|
+
// the scheduled recurring job. Holds the installer lock for the pass
|
|
195
|
+
// (mutually exclusive with installs / migrations / GC). Reuses the
|
|
196
|
+
// installer's registry/`.npmrc` resolution so audit + install never
|
|
197
|
+
// drift, and records advisories to the plugin's own Postgres tables
|
|
198
|
+
// (cluster-wide source of truth; the on-disk tree is pod-local).
|
|
199
|
+
const auditStore = createAuditStore(database);
|
|
200
|
+
const authClient = rpcClient.forPlugin(AuthApi);
|
|
201
|
+
const notificationClient = rpcClient.forPlugin(NotificationApi);
|
|
202
|
+
const triggerAudit = createAuditRunner({
|
|
203
|
+
installerLock,
|
|
204
|
+
auditStore,
|
|
205
|
+
loadCurrent: async () => {
|
|
206
|
+
const state = await installState.load();
|
|
207
|
+
const reg = await registry.get();
|
|
208
|
+
const list = await packages.list();
|
|
209
|
+
return {
|
|
210
|
+
lockfileHash: state.lockfileHash,
|
|
211
|
+
packages: list.map((p) => ({
|
|
212
|
+
name: p.name,
|
|
213
|
+
version: p.version,
|
|
214
|
+
enabled: p.enabled,
|
|
215
|
+
})),
|
|
216
|
+
ignoreScripts: reg.ignoreScripts,
|
|
217
|
+
};
|
|
218
|
+
},
|
|
219
|
+
scan: async ({ packages: pkgs, ignoreScripts }) => {
|
|
220
|
+
const reqConfig = await resolveRegistryRequestConfig({
|
|
221
|
+
registry,
|
|
222
|
+
registryToken,
|
|
223
|
+
logger,
|
|
224
|
+
});
|
|
225
|
+
const paths = storePaths(storeRoot);
|
|
226
|
+
const scanner = createAuditScanner({
|
|
227
|
+
scratchDir: path.join(paths.root, ".audit-scratch"),
|
|
228
|
+
// Same shared cache the installer's resolver uses, so the audit
|
|
229
|
+
// reuses already-fetched packages instead of re-downloading.
|
|
230
|
+
cacheDir: paths.cache,
|
|
231
|
+
registry: {
|
|
232
|
+
registryUrl: reqConfig.registryUrl,
|
|
233
|
+
scopedRegistries: reqConfig.scopedRegistries,
|
|
234
|
+
authToken: reqConfig.authToken,
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
return scanner.scan({
|
|
238
|
+
packages: pkgs.map((p) => ({
|
|
239
|
+
name: p.name,
|
|
240
|
+
version: p.version,
|
|
241
|
+
enabled: p.enabled,
|
|
242
|
+
})),
|
|
243
|
+
ignoreScripts,
|
|
244
|
+
});
|
|
245
|
+
},
|
|
246
|
+
getUserIds: async () => {
|
|
247
|
+
const users = await authClient.getUsers();
|
|
248
|
+
return users.map((u) => u.id);
|
|
249
|
+
},
|
|
250
|
+
filterManagers: (userIds) =>
|
|
251
|
+
authClient.filterUsersByAccessRule({
|
|
252
|
+
userIds,
|
|
253
|
+
accessRule: scriptPackagesAccess.manage.id,
|
|
254
|
+
}),
|
|
255
|
+
notifyUser: async ({ userId, title, body, importance, action }) => {
|
|
256
|
+
// sendTransactional's importance vocabulary is info|warning|critical.
|
|
257
|
+
const notification: {
|
|
258
|
+
title: string;
|
|
259
|
+
body: string;
|
|
260
|
+
importance: "info" | "warning" | "critical";
|
|
261
|
+
action?: { label: string; url: string };
|
|
262
|
+
} = { title, body, importance };
|
|
263
|
+
if (action) notification.action = action;
|
|
264
|
+
await notificationClient.sendTransactional({ userId, notification });
|
|
265
|
+
},
|
|
266
|
+
emitCompleted: async ({ lockfileHash, total }) => {
|
|
267
|
+
await signalService.broadcast(
|
|
268
|
+
SCRIPT_PACKAGES_AUDIT_COMPLETED_SIGNAL,
|
|
269
|
+
{ lockfileHash, total },
|
|
270
|
+
);
|
|
271
|
+
},
|
|
272
|
+
logger,
|
|
273
|
+
});
|
|
274
|
+
(env as unknown as EnvStash).triggerAudit = triggerAudit;
|
|
275
|
+
|
|
151
276
|
// Build the install orchestration. The resolver + active blob store
|
|
152
277
|
// are resolved lazily at install time so config/registry changes
|
|
153
278
|
// and store-plugin registration order don't matter.
|
|
@@ -254,6 +379,31 @@ export default createBackendPlugin({
|
|
|
254
379
|
return { started: true };
|
|
255
380
|
};
|
|
256
381
|
|
|
382
|
+
// GLOBAL sandbox policy: the single owning row lives in THIS plugin's
|
|
383
|
+
// ConfigService (shared Postgres, NOT pod-local). script-packages is
|
|
384
|
+
// the single source of truth — it registers the one process-wide policy
|
|
385
|
+
// provider that every script runner on this pod resolves through, so
|
|
386
|
+
// both script plugins read the identical value (no more last-writer-wins
|
|
387
|
+
// across two plugin-scoped rows). The runners FAIL CLOSED if this read
|
|
388
|
+
// throws, so a transient DB error never widens the sandbox.
|
|
389
|
+
const sandboxPolicy = createSandboxPolicyService({
|
|
390
|
+
configService: config,
|
|
391
|
+
});
|
|
392
|
+
registerScriptPackagesSandboxProvider({ service: sandboxPolicy });
|
|
393
|
+
|
|
394
|
+
// One-time, per-pod startup observability for the script sandbox: the
|
|
395
|
+
// host readiness banner AND the capability/effective-enforcement line
|
|
396
|
+
// for the configured global default. Both surfaces are emitted here, in
|
|
397
|
+
// process, by the single policy owner — the policy is read through the
|
|
398
|
+
// in-process `sandboxPolicy.read()` closure with NO RPC. This replaces
|
|
399
|
+
// the old per-script-plugin `getSandboxPolicy` RPC log, which 404'd when
|
|
400
|
+
// a script plugin's init ran before this plugin had mounted its router.
|
|
401
|
+
// Best-effort: never throws, never relaxes enforcement.
|
|
402
|
+
await logSandboxStartupReadiness({
|
|
403
|
+
logger,
|
|
404
|
+
readPolicy: () => sandboxPolicy.read(),
|
|
405
|
+
});
|
|
406
|
+
|
|
257
407
|
const router = createScriptPackagesRouter({
|
|
258
408
|
db: database,
|
|
259
409
|
blobStores,
|
|
@@ -261,7 +411,18 @@ export default createBackendPlugin({
|
|
|
261
411
|
triggerInstall,
|
|
262
412
|
triggerMigration,
|
|
263
413
|
triggerBlobGc,
|
|
414
|
+
triggerAudit,
|
|
264
415
|
registryToken,
|
|
416
|
+
sandboxPolicy,
|
|
417
|
+
// Push-on-change: broadcast the new policy to all connected
|
|
418
|
+
// satellites via the cluster-wide hook (each pod fans it out to its
|
|
419
|
+
// own satellites). No-op until `afterPluginsReady` wires `emitHook`;
|
|
420
|
+
// the durable row + connect-time relay are the backstop.
|
|
421
|
+
onSandboxPolicyChanged: async (policy) => {
|
|
422
|
+
await (env as unknown as EnvStash).emitSandboxPolicyChanged?.(
|
|
423
|
+
policy,
|
|
424
|
+
);
|
|
425
|
+
},
|
|
265
426
|
});
|
|
266
427
|
rpc.registerRouter(router, scriptPackagesContract);
|
|
267
428
|
|
|
@@ -282,6 +443,21 @@ export default createBackendPlugin({
|
|
|
282
443
|
TYPE_ACQUISITION_PATH_PREFIX,
|
|
283
444
|
);
|
|
284
445
|
|
|
446
|
+
// Raw, HTTP-cacheable route serving the running release's generated
|
|
447
|
+
// @checkstack/sdk editor bundle for the in-app script editor. Keyed by
|
|
448
|
+
// the running release version so a deployment upgrade refreshes the
|
|
449
|
+
// editor's SDK types (never stale); mismatched version -> 409.
|
|
450
|
+
// Mounted at `/api/script-packages/sdk-types/:releaseVersion`.
|
|
451
|
+
rpc.registerHttpHandler(
|
|
452
|
+
createSdkTypesHttpHandler({
|
|
453
|
+
auth,
|
|
454
|
+
getReleaseVersion: () => SDK_RELEASE_VERSION,
|
|
455
|
+
getSdkBundle: () => SDK_EDITOR_BUNDLE_DTS,
|
|
456
|
+
logger,
|
|
457
|
+
}),
|
|
458
|
+
SDK_TYPES_PATH_PREFIX,
|
|
459
|
+
);
|
|
460
|
+
|
|
285
461
|
logger.debug("✅ Script Packages Backend initialized.");
|
|
286
462
|
},
|
|
287
463
|
|
|
@@ -379,6 +555,13 @@ export default createBackendPlugin({
|
|
|
379
555
|
await emitHook(scriptPackagesChangedHook, { lockfileHash });
|
|
380
556
|
};
|
|
381
557
|
|
|
558
|
+
// Let the `setSandboxPolicy` handler (in init's router) broadcast the
|
|
559
|
+
// new global policy cluster-wide; each core pod's broadcast subscriber
|
|
560
|
+
// (in satellite-backend) pushes it to its own connected satellites.
|
|
561
|
+
stash.emitSandboxPolicyChanged = async (policy) => {
|
|
562
|
+
await emitHook(sandboxPolicyChangedHook, { policy });
|
|
563
|
+
};
|
|
564
|
+
|
|
382
565
|
// Reconcile this instance to a desired hash using the shared blob
|
|
383
566
|
// store (delta pull from whichever backend holds each blob).
|
|
384
567
|
const reconcileLocal = async (input: {
|
|
@@ -483,6 +666,49 @@ export default createBackendPlugin({
|
|
|
483
666
|
}
|
|
484
667
|
}
|
|
485
668
|
|
|
669
|
+
// Scheduled recurring vulnerability audit: run `bun audit` against the
|
|
670
|
+
// installed tree, persist advisories (cluster-wide source of truth),
|
|
671
|
+
// and notify `script-packages.manage` holders about newly-appeared /
|
|
672
|
+
// escalated advisories. The trigger (built in `init`) holds the
|
|
673
|
+
// installer advisory lock for the pass, so exactly one pod audits at a
|
|
674
|
+
// time and it is mutually exclusive with installs / migrations / GC.
|
|
675
|
+
// Runs daily (an admin-configurable interval is a follow-up).
|
|
676
|
+
const triggerAudit = stash.triggerAudit;
|
|
677
|
+
if (triggerAudit) {
|
|
678
|
+
try {
|
|
679
|
+
const auditQueue = queueManager.getQueue<Record<string, never>>(
|
|
680
|
+
"script-packages-audit",
|
|
681
|
+
);
|
|
682
|
+
await auditQueue.consume(
|
|
683
|
+
async () => {
|
|
684
|
+
const summary = await triggerAudit();
|
|
685
|
+
if (summary.ran) {
|
|
686
|
+
logger.debug(
|
|
687
|
+
`Scheduled audit: ${summary.total} advisor(ies), ${summary.notified} notified.`,
|
|
688
|
+
);
|
|
689
|
+
} else {
|
|
690
|
+
logger.debug(
|
|
691
|
+
`Scheduled audit skipped: ${summary.reason ?? "unknown"}.`,
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
},
|
|
695
|
+
{ consumerGroup: "script-packages-audit-worker", maxRetries: 0 },
|
|
696
|
+
);
|
|
697
|
+
await auditQueue.scheduleRecurring(
|
|
698
|
+
{},
|
|
699
|
+
{
|
|
700
|
+
jobId: "script-packages-audit-daily",
|
|
701
|
+
intervalSeconds: 24 * 60 * 60,
|
|
702
|
+
},
|
|
703
|
+
);
|
|
704
|
+
logger.debug("🛡️ Script-packages vulnerability audit scheduled (daily).");
|
|
705
|
+
} catch (error) {
|
|
706
|
+
logger.error(
|
|
707
|
+
`Failed to schedule vulnerability audit: ${extractErrorMessage(error)}`,
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
486
712
|
logger.debug("✅ Script Packages Backend afterPluginsReady complete.");
|
|
487
713
|
},
|
|
488
714
|
});
|
|
@@ -548,7 +774,16 @@ export {
|
|
|
548
774
|
type ReconcileDeps,
|
|
549
775
|
type ReconcileResult,
|
|
550
776
|
} from "./reconciler";
|
|
551
|
-
export { scriptPackagesChangedHook } from "./hooks";
|
|
777
|
+
export { scriptPackagesChangedHook, sandboxPolicyChangedHook } from "./hooks";
|
|
778
|
+
export {
|
|
779
|
+
createSandboxPolicyService,
|
|
780
|
+
registerScriptPackagesSandboxProvider,
|
|
781
|
+
type SandboxPolicyService,
|
|
782
|
+
} from "./sandbox-policy";
|
|
783
|
+
export {
|
|
784
|
+
logSandboxStartupReadiness,
|
|
785
|
+
type SandboxStartupLogger,
|
|
786
|
+
} from "./sandbox-startup-log";
|
|
552
787
|
export {
|
|
553
788
|
createCentralResolver,
|
|
554
789
|
type CentralResolverOptions,
|
|
@@ -561,6 +796,10 @@ export {
|
|
|
561
796
|
extractReferences,
|
|
562
797
|
} from "./package-types";
|
|
563
798
|
export { createTypeClosureHttpHandler } from "./type-acquisition-route";
|
|
799
|
+
export {
|
|
800
|
+
createSdkTypesHttpHandler,
|
|
801
|
+
SDK_BUNDLE_VIRTUAL_PATH,
|
|
802
|
+
} from "./sdk-types-route";
|
|
564
803
|
export {
|
|
565
804
|
resolveResolutionRoot,
|
|
566
805
|
resolveResolutionRootForHost,
|
|
@@ -590,5 +829,27 @@ export { sweepTreeGc, type TreeGcResult } from "./tree-gc";
|
|
|
590
829
|
export {
|
|
591
830
|
createLockfileHistoryStore,
|
|
592
831
|
createBlobGcStateStore,
|
|
832
|
+
createAuditStore,
|
|
833
|
+
type AuditStore,
|
|
593
834
|
} from "./stores";
|
|
835
|
+
export {
|
|
836
|
+
parseBunAudit,
|
|
837
|
+
countBySeverity,
|
|
838
|
+
meetsThreshold,
|
|
839
|
+
type ParseAuditResult,
|
|
840
|
+
} from "./audit-parse";
|
|
841
|
+
export {
|
|
842
|
+
computeAuditDelta,
|
|
843
|
+
advisoryKey,
|
|
844
|
+
type AuditDeltaInput,
|
|
845
|
+
type AuditDeltaResult,
|
|
846
|
+
} from "./audit-delta";
|
|
847
|
+
export {
|
|
848
|
+
createAuditScanner,
|
|
849
|
+
type AuditScanner,
|
|
850
|
+
type AuditScannerOptions,
|
|
851
|
+
type AuditScanResult,
|
|
852
|
+
type SpawnFn,
|
|
853
|
+
} from "./audit-scanner";
|
|
854
|
+
export { createAuditRunner, type AuditRunnerDeps } from "./audit-runner";
|
|
594
855
|
export * as schema from "./schema";
|
package/src/router.ts
CHANGED
|
@@ -10,9 +10,12 @@ import {
|
|
|
10
10
|
import type { RegistryTokenStore } from "./registry-token";
|
|
11
11
|
import {
|
|
12
12
|
scriptPackagesContract,
|
|
13
|
+
type AuditRunSummary,
|
|
13
14
|
type BlobGcSummary,
|
|
14
15
|
} from "@checkstack/script-packages-common";
|
|
15
16
|
import type { BlobStoreRegistry } from "./blob-store-registry";
|
|
17
|
+
import type { SandboxPolicyService } from "./sandbox-policy";
|
|
18
|
+
import type { SandboxPolicy } from "@checkstack/common";
|
|
16
19
|
import {
|
|
17
20
|
createPackageStore,
|
|
18
21
|
createRegistryConfigStore,
|
|
@@ -20,6 +23,7 @@ import {
|
|
|
20
23
|
createStorageConfigStore,
|
|
21
24
|
createSatelliteStateStore,
|
|
22
25
|
createBlobGcStateStore,
|
|
26
|
+
createAuditStore,
|
|
23
27
|
} from "./stores";
|
|
24
28
|
import { createInstallStateStore } from "./install-state-store";
|
|
25
29
|
import { resolveRegistryRequestConfig } from "./registry-request-config";
|
|
@@ -50,11 +54,28 @@ export interface ScriptPackagesRouterDeps {
|
|
|
50
54
|
* (wires the blob stores + retention/grace). Returns a summary.
|
|
51
55
|
*/
|
|
52
56
|
triggerBlobGc(): Promise<BlobGcSummary>;
|
|
57
|
+
/**
|
|
58
|
+
* Run a vulnerability-audit pass on demand (elected via the installer
|
|
59
|
+
* advisory lock; refuses while an install / migration / GC is in flight).
|
|
60
|
+
* Provided by the plugin (wires the scanner + notification path).
|
|
61
|
+
*/
|
|
62
|
+
triggerAudit(): Promise<AuditRunSummary>;
|
|
53
63
|
/**
|
|
54
64
|
* Registry auth-token store, backed by the secrets platform's internal
|
|
55
65
|
* secrets. Provided by the plugin (which injects `internalSecretsRef`).
|
|
56
66
|
*/
|
|
57
67
|
registryToken: RegistryTokenStore;
|
|
68
|
+
/**
|
|
69
|
+
* The GLOBAL script-sandbox policy service (single owning row). Powers the
|
|
70
|
+
* admin `getSandboxPolicy` / `setSandboxPolicy` endpoints.
|
|
71
|
+
*/
|
|
72
|
+
sandboxPolicy: SandboxPolicyService;
|
|
73
|
+
/**
|
|
74
|
+
* Called after a successful `setSandboxPolicy` with the resolved policy, so
|
|
75
|
+
* the plugin can broadcast it to all connected satellites (push-on-change).
|
|
76
|
+
* Provided by the plugin (wires the broadcast hook).
|
|
77
|
+
*/
|
|
78
|
+
onSandboxPolicyChanged(policy: SandboxPolicy): Promise<void>;
|
|
58
79
|
}
|
|
59
80
|
|
|
60
81
|
export function createScriptPackagesRouter({
|
|
@@ -64,7 +85,10 @@ export function createScriptPackagesRouter({
|
|
|
64
85
|
triggerInstall,
|
|
65
86
|
triggerMigration,
|
|
66
87
|
triggerBlobGc,
|
|
88
|
+
triggerAudit,
|
|
67
89
|
registryToken,
|
|
90
|
+
sandboxPolicy,
|
|
91
|
+
onSandboxPolicyChanged,
|
|
68
92
|
}: ScriptPackagesRouterDeps) {
|
|
69
93
|
const packages = createPackageStore(db);
|
|
70
94
|
const registry = createRegistryConfigStore(db);
|
|
@@ -73,6 +97,7 @@ export function createScriptPackagesRouter({
|
|
|
73
97
|
const satellites = createSatelliteStateStore(db);
|
|
74
98
|
const installState = createInstallStateStore(db);
|
|
75
99
|
const blobGcState = createBlobGcStateStore(db);
|
|
100
|
+
const auditStore = createAuditStore(db);
|
|
76
101
|
|
|
77
102
|
const os = implement(scriptPackagesContract)
|
|
78
103
|
.$context<RpcContext>()
|
|
@@ -221,6 +246,17 @@ export function createScriptPackagesRouter({
|
|
|
221
246
|
|
|
222
247
|
getBlobGcState: os.getBlobGcState.handler(async () => blobGcState.get()),
|
|
223
248
|
|
|
249
|
+
// ─── Vulnerability audit ──────────────────────────────────────────────
|
|
250
|
+
getAuditState: os.getAuditState.handler(async () => {
|
|
251
|
+
const state = await auditStore.getState();
|
|
252
|
+
const advisories = state.lockfileHash
|
|
253
|
+
? await auditStore.advisoriesForHash(state.lockfileHash)
|
|
254
|
+
: [];
|
|
255
|
+
return { state, advisories };
|
|
256
|
+
}),
|
|
257
|
+
|
|
258
|
+
auditNow: os.auditNow.handler(async () => triggerAudit()),
|
|
259
|
+
|
|
224
260
|
// ─── Per-host status ──────────────────────────────────────────────────
|
|
225
261
|
listSatelliteSyncState: os.listSatelliteSyncState.handler(async () => ({
|
|
226
262
|
items: await satellites.list(),
|
|
@@ -233,6 +269,19 @@ export function createScriptPackagesRouter({
|
|
|
233
269
|
},
|
|
234
270
|
),
|
|
235
271
|
|
|
272
|
+
// ─── Global sandbox policy (admin-only) ───────────────────────────────
|
|
273
|
+
getSandboxPolicy: os.getSandboxPolicy.handler(async () =>
|
|
274
|
+
sandboxPolicy.read(),
|
|
275
|
+
),
|
|
276
|
+
|
|
277
|
+
setSandboxPolicy: os.setSandboxPolicy.handler(async ({ input }) => {
|
|
278
|
+
const resolved = await sandboxPolicy.write(input);
|
|
279
|
+
// Push-on-change: relay the new policy to all connected satellites. The
|
|
280
|
+
// broadcast is best-effort liveness; satellites re-pull on (re)connect.
|
|
281
|
+
await onSandboxPolicyChanged(resolved);
|
|
282
|
+
return resolved;
|
|
283
|
+
}),
|
|
284
|
+
|
|
236
285
|
// ─── Authoring / runtime ──────────────────────────────────────────────
|
|
237
286
|
getInstallState: os.getInstallState.handler(async () => installState.load()),
|
|
238
287
|
|