@checkstack/script-packages-backend 0.2.1 → 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 +92 -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,105 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { call } from "@orpc/server";
|
|
3
|
+
import {
|
|
4
|
+
createMockRpcContext,
|
|
5
|
+
resolveDefaultSandboxProfile,
|
|
6
|
+
type SafeDatabase,
|
|
7
|
+
} from "@checkstack/backend-api";
|
|
8
|
+
import { scriptSandboxAccess } from "@checkstack/script-packages-common";
|
|
9
|
+
import type { SandboxPolicy } from "@checkstack/common";
|
|
10
|
+
import { createScriptPackagesRouter } from "./router";
|
|
11
|
+
import type { SandboxPolicyService } from "./sandbox-policy";
|
|
12
|
+
import * as schema from "./schema";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Router-level permission-gating test: a real `getSandboxPolicy` /
|
|
16
|
+
* `setSandboxPolicy` call through `autoAuthMiddleware` must be REJECTED for a
|
|
17
|
+
* user lacking `script-packages.script-sandbox.manage`, and accepted for one
|
|
18
|
+
* who holds it. This proves the admin gate end-to-end (not just the contract
|
|
19
|
+
* meta).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const QUALIFIED = `script-packages.${scriptSandboxAccess.manage.id}`;
|
|
23
|
+
|
|
24
|
+
function buildRouter(service: SandboxPolicyService) {
|
|
25
|
+
// Only the sandbox-policy deps are exercised; the rest are unused stubs.
|
|
26
|
+
const unused = () => {
|
|
27
|
+
throw new Error("not used in this test");
|
|
28
|
+
};
|
|
29
|
+
return createScriptPackagesRouter({
|
|
30
|
+
db: {} as unknown as SafeDatabase<typeof schema>,
|
|
31
|
+
blobStores: { ids: () => [], has: () => false } as never,
|
|
32
|
+
logger: { debug() {}, info() {}, warn() {}, error() {} } as never,
|
|
33
|
+
triggerInstall: unused as never,
|
|
34
|
+
triggerMigration: unused as never,
|
|
35
|
+
triggerBlobGc: unused as never,
|
|
36
|
+
triggerAudit: unused as never,
|
|
37
|
+
registryToken: {} as never,
|
|
38
|
+
sandboxPolicy: service,
|
|
39
|
+
onSandboxPolicyChanged: async () => {},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function stubService(): SandboxPolicyService & { written: SandboxPolicy[] } {
|
|
44
|
+
const written: SandboxPolicy[] = [];
|
|
45
|
+
return {
|
|
46
|
+
written,
|
|
47
|
+
read: async () => resolveDefaultSandboxProfile(),
|
|
48
|
+
write: async () => {
|
|
49
|
+
const resolved = resolveDefaultSandboxProfile();
|
|
50
|
+
written.push(resolved);
|
|
51
|
+
return resolved;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("getSandboxPolicy / setSandboxPolicy permission gating", () => {
|
|
57
|
+
it("REJECTS read for a user without the dedicated permission", async () => {
|
|
58
|
+
const router = buildRouter(stubService());
|
|
59
|
+
const ctx = createMockRpcContext({
|
|
60
|
+
pluginMetadata: { pluginId: "script-packages" },
|
|
61
|
+
user: { type: "user", id: "u1", accessRules: [] },
|
|
62
|
+
});
|
|
63
|
+
expect(
|
|
64
|
+
call(router.getSandboxPolicy, undefined, { context: ctx }),
|
|
65
|
+
).rejects.toThrow();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("REJECTS write for a user without the dedicated permission", async () => {
|
|
69
|
+
const router = buildRouter(stubService());
|
|
70
|
+
const ctx = createMockRpcContext({
|
|
71
|
+
pluginMetadata: { pluginId: "script-packages" },
|
|
72
|
+
// Holds the OTHER script-packages permission but NOT script-sandbox.manage.
|
|
73
|
+
user: {
|
|
74
|
+
type: "user",
|
|
75
|
+
id: "u1",
|
|
76
|
+
accessRules: ["script-packages.script-packages.manage"],
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
expect(
|
|
80
|
+
call(router.setSandboxPolicy, { enabled: false }, { context: ctx }),
|
|
81
|
+
).rejects.toThrow();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("ALLOWS read+write for a user holding the dedicated permission", async () => {
|
|
85
|
+
const service = stubService();
|
|
86
|
+
const router = buildRouter(service);
|
|
87
|
+
const ctx = createMockRpcContext({
|
|
88
|
+
pluginMetadata: { pluginId: "script-packages" },
|
|
89
|
+
user: { type: "user", id: "admin", accessRules: [QUALIFIED] },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const policy = await call(router.getSandboxPolicy, undefined, {
|
|
93
|
+
context: ctx,
|
|
94
|
+
});
|
|
95
|
+
expect(policy.enabled).toBe(true);
|
|
96
|
+
|
|
97
|
+
const written = await call(
|
|
98
|
+
router.setSandboxPolicy,
|
|
99
|
+
{ network: { mode: "deny" } },
|
|
100
|
+
{ context: ctx },
|
|
101
|
+
);
|
|
102
|
+
expect(written).toBeDefined();
|
|
103
|
+
expect(service.written.length).toBe(1);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach } from "bun:test";
|
|
2
|
+
import type { z } from "zod";
|
|
3
|
+
import {
|
|
4
|
+
type ConfigService,
|
|
5
|
+
type Migration,
|
|
6
|
+
resolveActiveSandboxPolicy,
|
|
7
|
+
resetSandboxPolicyProvider,
|
|
8
|
+
FAIL_CLOSED_SANDBOX_PROFILE,
|
|
9
|
+
resolveDefaultSandboxProfile,
|
|
10
|
+
} from "@checkstack/backend-api";
|
|
11
|
+
import {
|
|
12
|
+
createSandboxPolicyService,
|
|
13
|
+
registerScriptPackagesSandboxProvider,
|
|
14
|
+
} from "./sandbox-policy";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* In-memory ConfigService over a SHARED backing store, simulating the durable
|
|
18
|
+
* Postgres `plugin_configs` row owned by the `script-packages` plugin. This is
|
|
19
|
+
* the SINGLE source of truth: the settings-page write and every runner's read
|
|
20
|
+
* go through this one row.
|
|
21
|
+
*/
|
|
22
|
+
function fakeConfigService(store: Map<string, unknown>): ConfigService {
|
|
23
|
+
return {
|
|
24
|
+
async set<T>(
|
|
25
|
+
configId: string,
|
|
26
|
+
_schema: z.ZodType<T>,
|
|
27
|
+
_version: number,
|
|
28
|
+
data: T,
|
|
29
|
+
_migrations?: Migration<unknown, unknown>[],
|
|
30
|
+
): Promise<void> {
|
|
31
|
+
store.set(configId, data);
|
|
32
|
+
},
|
|
33
|
+
async get<T>(
|
|
34
|
+
configId: string,
|
|
35
|
+
_schema: z.ZodType<T>,
|
|
36
|
+
_version: number,
|
|
37
|
+
_migrations?: Migration<unknown, unknown>[],
|
|
38
|
+
): Promise<T | undefined> {
|
|
39
|
+
return store.has(configId) ? (store.get(configId) as T) : undefined;
|
|
40
|
+
},
|
|
41
|
+
async getRedacted<T>(): Promise<Partial<T> | undefined> {
|
|
42
|
+
throw new Error("not used in these tests");
|
|
43
|
+
},
|
|
44
|
+
async delete(configId: string): Promise<void> {
|
|
45
|
+
store.delete(configId);
|
|
46
|
+
},
|
|
47
|
+
async list(): Promise<string[]> {
|
|
48
|
+
return [...store.keys()];
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("createSandboxPolicyService", () => {
|
|
54
|
+
it("read falls back to the shipped safe default when nothing is stored", async () => {
|
|
55
|
+
const service = createSandboxPolicyService({
|
|
56
|
+
configService: fakeConfigService(new Map()),
|
|
57
|
+
});
|
|
58
|
+
const policy = await service.read();
|
|
59
|
+
expect(policy).toEqual(resolveDefaultSandboxProfile());
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("write persists a partial over the safe default and read returns it", async () => {
|
|
63
|
+
const store = new Map<string, unknown>();
|
|
64
|
+
const service = createSandboxPolicyService({
|
|
65
|
+
configService: fakeConfigService(store),
|
|
66
|
+
});
|
|
67
|
+
const written = await service.write({ network: { mode: "deny" } });
|
|
68
|
+
expect(written.network.mode).toBe("deny");
|
|
69
|
+
// Unspecified layers preserved from the safe default (not re-widened).
|
|
70
|
+
expect(written.filesystem.mode).toBe("scratch-plus-ro");
|
|
71
|
+
|
|
72
|
+
const readBack = await service.read();
|
|
73
|
+
expect(readBack).toEqual(written);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("single source of truth across both script plugins", () => {
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
resetSandboxPolicyProvider();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("both runners resolve the SAME stored row via the one registered provider", async () => {
|
|
83
|
+
// ONE shared store = the single durable row owned by script-packages.
|
|
84
|
+
const store = new Map<string, unknown>();
|
|
85
|
+
const service = createSandboxPolicyService({
|
|
86
|
+
configService: fakeConfigService(store),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Admin sets a distinctive policy through the (single) write path.
|
|
90
|
+
await service.write({ resources: { cpuSeconds: 17 } });
|
|
91
|
+
|
|
92
|
+
// script-packages registers the ONE process-wide provider.
|
|
93
|
+
registerScriptPackagesSandboxProvider({ service });
|
|
94
|
+
|
|
95
|
+
// Whichever script plugin's runner resolves the active policy gets the
|
|
96
|
+
// identical row - there is no second, divergent source.
|
|
97
|
+
const a = await resolveActiveSandboxPolicy();
|
|
98
|
+
const b = await resolveActiveSandboxPolicy();
|
|
99
|
+
expect(a.failedClosed).toBe(false);
|
|
100
|
+
expect(a.policy.resources.cpuSeconds).toBe(17);
|
|
101
|
+
expect(b.policy).toEqual(a.policy);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("a provider read error fails closed (does not widen the sandbox)", async () => {
|
|
105
|
+
resetSandboxPolicyProvider();
|
|
106
|
+
const service = {
|
|
107
|
+
read: async () => {
|
|
108
|
+
throw new Error("transient db error");
|
|
109
|
+
},
|
|
110
|
+
write: async () => {
|
|
111
|
+
throw new Error("unused");
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
registerScriptPackagesSandboxProvider({ service });
|
|
115
|
+
const resolved = await resolveActiveSandboxPolicy();
|
|
116
|
+
expect(resolved.failedClosed).toBe(true);
|
|
117
|
+
expect(resolved.policy).toEqual(FAIL_CLOSED_SANDBOX_PROFILE);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ConfigService,
|
|
3
|
+
readGlobalSandboxDefault,
|
|
4
|
+
writeGlobalSandboxDefault,
|
|
5
|
+
registerSandboxPolicyProvider,
|
|
6
|
+
type SandboxPolicy,
|
|
7
|
+
type SandboxPolicyInput,
|
|
8
|
+
} from "@checkstack/backend-api";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The GLOBAL script-sandbox policy, owned by the `script-packages` plugin.
|
|
12
|
+
*
|
|
13
|
+
* SINGLE SOURCE OF TRUTH (state-and-scale rule):
|
|
14
|
+
* 1. WHERE IT LIVES: one durable row in the shared Postgres `plugin_configs`
|
|
15
|
+
* table, scoped to the `script-packages` plugin id (this plugin's own
|
|
16
|
+
* {@link ConfigService}). It is NOT pod-local and NOT duplicated.
|
|
17
|
+
* 2. SAME ANSWER ON EVERY POD: every core pod reads the same row, so the
|
|
18
|
+
* resolved policy is identical everywhere.
|
|
19
|
+
* 3. NOT DUPLICATED: previously BOTH script plugins
|
|
20
|
+
* (`integration-script-backend`, `healthcheck-script-backend`) registered a
|
|
21
|
+
* policy provider that read THEIR OWN plugin-scoped row, so the
|
|
22
|
+
* process-global provider was last-writer-wins and each plugin read a
|
|
23
|
+
* different row. That is fixed here: `script-packages` is the single owner.
|
|
24
|
+
* It registers the one process-wide provider (see
|
|
25
|
+
* {@link registerScriptPackagesSandboxProvider}); the script plugins read
|
|
26
|
+
* this same value over RPC (`getSandboxPolicy`) for their own startup logs
|
|
27
|
+
* and no longer register a competing provider on the core pod.
|
|
28
|
+
*/
|
|
29
|
+
export interface SandboxPolicyService {
|
|
30
|
+
/** Resolve the durable global policy (safe default when nothing stored). */
|
|
31
|
+
read(): Promise<SandboxPolicy>;
|
|
32
|
+
/**
|
|
33
|
+
* Persist a PARTIAL policy override (merged over the safe default so
|
|
34
|
+
* unspecified layers are preserved) and return the fully-resolved policy.
|
|
35
|
+
*/
|
|
36
|
+
write(policy: SandboxPolicyInput): Promise<SandboxPolicy>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build the global sandbox-policy service over the `script-packages` plugin's
|
|
41
|
+
* {@link ConfigService}. Thin wrapper around the platform read/write helpers so
|
|
42
|
+
* the single owning row is the only place the policy is read or written.
|
|
43
|
+
*/
|
|
44
|
+
export function createSandboxPolicyService({
|
|
45
|
+
configService,
|
|
46
|
+
}: {
|
|
47
|
+
configService: ConfigService;
|
|
48
|
+
}): SandboxPolicyService {
|
|
49
|
+
return {
|
|
50
|
+
read: () => readGlobalSandboxDefault({ configService }),
|
|
51
|
+
write: (policy) => writeGlobalSandboxDefault({ configService, policy }),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Register the ONE process-wide active sandbox policy provider, backed by the
|
|
57
|
+
* single owning row. Called once from `script-packages` init on the core pod.
|
|
58
|
+
* The runners resolve the active policy through this provider and FAIL CLOSED
|
|
59
|
+
* if it throws (transient DB error), so a read failure never widens the
|
|
60
|
+
* sandbox.
|
|
61
|
+
*/
|
|
62
|
+
export function registerScriptPackagesSandboxProvider({
|
|
63
|
+
service,
|
|
64
|
+
}: {
|
|
65
|
+
service: Pick<SandboxPolicyService, "read">;
|
|
66
|
+
}): void {
|
|
67
|
+
registerSandboxPolicyProvider(() => service.read());
|
|
68
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type { SandboxCapabilities, SandboxPolicy } from "@checkstack/backend-api";
|
|
3
|
+
import { resolveDefaultSandboxProfile } from "@checkstack/backend-api";
|
|
4
|
+
import { logSandboxStartupReadiness } from "./sandbox-startup-log";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Linux capabilities with every OS-level primitive available, so
|
|
8
|
+
* `assessSandboxHostReadiness` reports `enforceable: true`.
|
|
9
|
+
*/
|
|
10
|
+
const LINUX_READY: SandboxCapabilities = {
|
|
11
|
+
platform: "linux",
|
|
12
|
+
euidIsRoot: false,
|
|
13
|
+
hasPrlimit: true,
|
|
14
|
+
rlimitNative: true,
|
|
15
|
+
wrapper: "bwrap",
|
|
16
|
+
userNamespaces: true,
|
|
17
|
+
netNamespaces: true,
|
|
18
|
+
userNsCreatable: true,
|
|
19
|
+
netEgressIface: null,
|
|
20
|
+
netEgressAddressing: null,
|
|
21
|
+
netEgressRootless: true,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** A non-Linux host: OS-level isolation is not enforceable. */
|
|
25
|
+
const MACOS_HOST: SandboxCapabilities = {
|
|
26
|
+
...LINUX_READY,
|
|
27
|
+
platform: "darwin",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
interface LogCall {
|
|
31
|
+
level: "info" | "warn" | "debug";
|
|
32
|
+
message: string;
|
|
33
|
+
args: unknown[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function recordingLogger() {
|
|
37
|
+
const calls: LogCall[] = [];
|
|
38
|
+
return {
|
|
39
|
+
calls,
|
|
40
|
+
logger: {
|
|
41
|
+
info: (message: string, ...args: unknown[]) =>
|
|
42
|
+
calls.push({ level: "info", message, args }),
|
|
43
|
+
warn: (message: string, ...args: unknown[]) =>
|
|
44
|
+
calls.push({ level: "warn", message, args }),
|
|
45
|
+
debug: (message: string, ...args: unknown[]) =>
|
|
46
|
+
calls.push({ level: "debug", message, args }),
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("logSandboxStartupReadiness", () => {
|
|
52
|
+
it("reads the policy IN-PROCESS (no RPC) and logs the capability line", async () => {
|
|
53
|
+
let reads = 0;
|
|
54
|
+
const policy: SandboxPolicy = resolveDefaultSandboxProfile();
|
|
55
|
+
const { logger, calls } = recordingLogger();
|
|
56
|
+
|
|
57
|
+
await logSandboxStartupReadiness({
|
|
58
|
+
logger,
|
|
59
|
+
readPolicy: async () => {
|
|
60
|
+
reads += 1;
|
|
61
|
+
return policy;
|
|
62
|
+
},
|
|
63
|
+
caps: LINUX_READY,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// The policy was resolved through the supplied in-process reader, NOT an
|
|
67
|
+
// RPC round-trip. This is the regression guard: the startup readiness log
|
|
68
|
+
// no longer depends on the `getSandboxPolicy` route being mounted.
|
|
69
|
+
expect(reads).toBe(1);
|
|
70
|
+
|
|
71
|
+
const capabilityLog = calls.find(
|
|
72
|
+
(c) => c.message === "script sandbox capabilities",
|
|
73
|
+
);
|
|
74
|
+
expect(capabilityLog).toBeDefined();
|
|
75
|
+
expect(capabilityLog?.level).toBe("info");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("does not throw when the in-process read fails (best-effort log)", async () => {
|
|
79
|
+
const { logger, calls } = recordingLogger();
|
|
80
|
+
|
|
81
|
+
await logSandboxStartupReadiness({
|
|
82
|
+
logger,
|
|
83
|
+
readPolicy: async () => {
|
|
84
|
+
throw new Error("transient db error");
|
|
85
|
+
},
|
|
86
|
+
caps: LINUX_READY,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// A failed read must not crash startup; it warns and moves on. Crucially it
|
|
90
|
+
// does NOT surface a "Not Found" / 404 (the old RPC failure mode).
|
|
91
|
+
const warned = calls.find((c) => c.level === "warn");
|
|
92
|
+
expect(warned).toBeDefined();
|
|
93
|
+
expect(warned?.message).not.toContain("Not Found");
|
|
94
|
+
expect(warned?.message).not.toContain("404");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("emits the high-visibility readiness banner on a host that cannot enforce", async () => {
|
|
98
|
+
const { logger, calls } = recordingLogger();
|
|
99
|
+
|
|
100
|
+
await logSandboxStartupReadiness({
|
|
101
|
+
logger,
|
|
102
|
+
readPolicy: async () => resolveDefaultSandboxProfile(),
|
|
103
|
+
caps: MACOS_HOST,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const banner = calls.find(
|
|
107
|
+
(c) =>
|
|
108
|
+
c.level === "warn" &&
|
|
109
|
+
c.message.includes("OS-LEVEL ISOLATION UNAVAILABLE"),
|
|
110
|
+
);
|
|
111
|
+
expect(banner).toBeDefined();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("logs the positive readiness line at debug on an enforceable host", async () => {
|
|
115
|
+
const { logger, calls } = recordingLogger();
|
|
116
|
+
|
|
117
|
+
await logSandboxStartupReadiness({
|
|
118
|
+
logger,
|
|
119
|
+
readPolicy: async () => resolveDefaultSandboxProfile(),
|
|
120
|
+
caps: LINUX_READY,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const ok = calls.find(
|
|
124
|
+
(c) => c.level === "debug" && c.message.includes("OS-level isolation"),
|
|
125
|
+
);
|
|
126
|
+
expect(ok).toBeDefined();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assessSandboxHostReadiness,
|
|
3
|
+
detectSandboxCapabilities,
|
|
4
|
+
formatSandboxReadinessBanner,
|
|
5
|
+
logSandboxCapabilitiesAtStartup,
|
|
6
|
+
type SandboxCapabilities,
|
|
7
|
+
type SandboxLogger,
|
|
8
|
+
type SandboxPolicy,
|
|
9
|
+
} from "@checkstack/backend-api";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The startup logger surface this module needs: the narrow {@link SandboxLogger}
|
|
13
|
+
* (`info` / `warn`) plus a `debug` channel for the positive readiness line.
|
|
14
|
+
*/
|
|
15
|
+
export interface SandboxStartupLogger extends SandboxLogger {
|
|
16
|
+
debug(message: string, ...args: unknown[]): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Emit the one-time, per-pod script-sandbox startup observability — the host
|
|
21
|
+
* readiness banner AND the capability/enforcement line for the configured
|
|
22
|
+
* global default — entirely IN PROCESS.
|
|
23
|
+
*
|
|
24
|
+
* WHY THIS LIVES HERE (and not in the script plugins): `script-packages` is the
|
|
25
|
+
* single owner of the global sandbox policy (it holds the durable row and
|
|
26
|
+
* registers the one process-wide provider). It can therefore read the policy
|
|
27
|
+
* directly via `readPolicy` — the in-process {@link SandboxPolicyService.read}
|
|
28
|
+
* closure — with NO HTTP RPC round-trip.
|
|
29
|
+
*
|
|
30
|
+
* The previous design had BOTH script plugins
|
|
31
|
+
* (`healthcheck-script-backend`, `integration-script-backend`) call the
|
|
32
|
+
* `getSandboxPolicy` oRPC during their own init to log this line. That route is
|
|
33
|
+
* only mounted once `script-packages` init runs `rpc.registerRouter`, and the
|
|
34
|
+
* plugin init order is topologically derived from runtime service-provider
|
|
35
|
+
* edges (NOT npm package deps) — the script plugins consume no service that
|
|
36
|
+
* `script-packages` provides, so nothing forces `script-packages` to init
|
|
37
|
+
* first. When a script plugin initialised first, the route was absent and the
|
|
38
|
+
* self-loop POST returned `404 Not Found`, producing the noisy
|
|
39
|
+
* "Could not log script sandbox capabilities at startup: Error: Not Found"
|
|
40
|
+
* warnings. Reading in-process removes that ordering dependency, removes the
|
|
41
|
+
* duplicate (two plugins logging the same host/policy), and never touches the
|
|
42
|
+
* enforcement path (runners still resolve via `resolveActiveSandboxPolicy`).
|
|
43
|
+
*
|
|
44
|
+
* Best-effort: a read failure is logged as a warning and swallowed so a
|
|
45
|
+
* transient DB error never crashes boot.
|
|
46
|
+
*/
|
|
47
|
+
export async function logSandboxStartupReadiness({
|
|
48
|
+
logger,
|
|
49
|
+
readPolicy,
|
|
50
|
+
caps = detectSandboxCapabilities(),
|
|
51
|
+
}: {
|
|
52
|
+
logger: SandboxStartupLogger;
|
|
53
|
+
/** In-process read of the durable global default policy. */
|
|
54
|
+
readPolicy: () => Promise<SandboxPolicy>;
|
|
55
|
+
/** Detected host capabilities; injectable for tests. */
|
|
56
|
+
caps?: SandboxCapabilities;
|
|
57
|
+
}): Promise<void> {
|
|
58
|
+
// Surface, once at startup, whether this host can enforce the OS-level
|
|
59
|
+
// sandbox. Under the secure fail-closed default every run would otherwise
|
|
60
|
+
// fail with an opaque "sandbox unavailable" error on a non-Linux / unprimed
|
|
61
|
+
// host; the banner points the developer at the supported paths. It does NOT
|
|
62
|
+
// relax enforcement — the durable global policy stays the single source of
|
|
63
|
+
// truth.
|
|
64
|
+
const readiness = assessSandboxHostReadiness({ caps });
|
|
65
|
+
if (readiness.enforceable) {
|
|
66
|
+
logger.debug(
|
|
67
|
+
"✅ Script sandbox: OS-level isolation is available on this host.",
|
|
68
|
+
);
|
|
69
|
+
} else {
|
|
70
|
+
logger.warn(formatSandboxReadinessBanner({ readiness }));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The detailed capability + effective-enforcement line for the configured
|
|
74
|
+
// global default. Best-effort: a read failure must not crash startup.
|
|
75
|
+
try {
|
|
76
|
+
const globalDefault = await readPolicy();
|
|
77
|
+
logSandboxCapabilitiesAtStartup({ logger, globalDefault, caps });
|
|
78
|
+
} catch (error) {
|
|
79
|
+
logger.warn(
|
|
80
|
+
`Could not log script sandbox capabilities at startup: ${String(error)}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/schema.ts
CHANGED
|
@@ -7,8 +7,13 @@ import {
|
|
|
7
7
|
boolean,
|
|
8
8
|
timestamp,
|
|
9
9
|
index,
|
|
10
|
+
doublePrecision,
|
|
11
|
+
primaryKey,
|
|
10
12
|
} from "drizzle-orm/pg-core";
|
|
11
|
-
import type {
|
|
13
|
+
import type {
|
|
14
|
+
AuditSeverity,
|
|
15
|
+
ManifestEntry,
|
|
16
|
+
} from "@checkstack/script-packages-common";
|
|
12
17
|
|
|
13
18
|
/**
|
|
14
19
|
* Drizzle schema for the script-packages plugin.
|
|
@@ -152,6 +157,53 @@ export const scriptPackageBlobGcState = pgTable(
|
|
|
152
157
|
},
|
|
153
158
|
);
|
|
154
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Vulnerability advisories found by the scheduled `bun audit` pass, keyed by
|
|
162
|
+
* the audited `lockfile_hash` + advisory id. This is the cluster-wide source
|
|
163
|
+
* of truth for the audit findings (the on-disk node_modules tree is pod-local
|
|
164
|
+
* and ephemeral; advisories live here so every pod returns the same answer).
|
|
165
|
+
* The elected runner replaces the whole row set for a given hash on each run.
|
|
166
|
+
*
|
|
167
|
+
* `first_seen_at` / `notified` let the delta logic suppress repeat-notify:
|
|
168
|
+
* `notified` records that holders were already told about this advisory at
|
|
169
|
+
* (at least) its current severity, so a subsequent unchanged run stays quiet
|
|
170
|
+
* even across a redeploy.
|
|
171
|
+
*/
|
|
172
|
+
export const scriptPackageAuditAdvisory = pgTable(
|
|
173
|
+
"script_package_audit_advisory",
|
|
174
|
+
{
|
|
175
|
+
lockfileHash: text("lockfile_hash").notNull(),
|
|
176
|
+
advisoryId: text("advisory_id").notNull(),
|
|
177
|
+
packageName: text("package_name").notNull(),
|
|
178
|
+
title: text("title").notNull().default(""),
|
|
179
|
+
/** "low" | "moderate" | "high" | "critical" */
|
|
180
|
+
severity: text("severity").$type<AuditSeverity>().notNull(),
|
|
181
|
+
vulnerableVersions: text("vulnerable_versions").notNull().default(""),
|
|
182
|
+
url: text("url"),
|
|
183
|
+
cvssScore: doublePrecision("cvss_score"),
|
|
184
|
+
/** Whether holders were already notified at the current severity. */
|
|
185
|
+
notified: boolean("notified").notNull().default(false),
|
|
186
|
+
firstSeenAt: timestamp("first_seen_at").defaultNow().notNull(),
|
|
187
|
+
},
|
|
188
|
+
(t) => ({
|
|
189
|
+
pk: primaryKey({ columns: [t.lockfileHash, t.advisoryId, t.packageName] }),
|
|
190
|
+
hashIdx: index("script_package_audit_advisory_hash_idx").on(t.lockfileHash),
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
/** Singleton audit last-run summary (for the settings UI). */
|
|
195
|
+
export const scriptPackageAuditState = pgTable("script_package_audit_state", {
|
|
196
|
+
id: text("id").primaryKey().default("singleton"),
|
|
197
|
+
lastRunAt: timestamp("last_run_at"),
|
|
198
|
+
lockfileHash: text("lockfile_hash"),
|
|
199
|
+
total: integer("total").notNull().default(0),
|
|
200
|
+
countLow: integer("count_low").notNull().default(0),
|
|
201
|
+
countModerate: integer("count_moderate").notNull().default(0),
|
|
202
|
+
countHigh: integer("count_high").notNull().default(0),
|
|
203
|
+
countCritical: integer("count_critical").notNull().default(0),
|
|
204
|
+
errorMessage: text("error_message"),
|
|
205
|
+
});
|
|
206
|
+
|
|
155
207
|
/** Per-satellite reconcile state (satellites are individually addressable). */
|
|
156
208
|
export const scriptPackageSatelliteState = pgTable(
|
|
157
209
|
"script_package_satellite_state",
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { AuthService, AuthUser, Logger } from "@checkstack/backend-api";
|
|
3
|
+
import {
|
|
4
|
+
buildSdkTypesPath,
|
|
5
|
+
scriptPackagesAccess,
|
|
6
|
+
SDK_TYPES_PATH_PREFIX,
|
|
7
|
+
} from "@checkstack/script-packages-common";
|
|
8
|
+
import {
|
|
9
|
+
createSdkTypesHttpHandler,
|
|
10
|
+
SDK_BUNDLE_VIRTUAL_PATH,
|
|
11
|
+
} from "./sdk-types-route";
|
|
12
|
+
|
|
13
|
+
const RELEASE_VERSION = "0.93.0";
|
|
14
|
+
const BUNDLE = 'declare module "@checkstack/sdk/healthcheck" {}\n';
|
|
15
|
+
|
|
16
|
+
/** No-op logger satisfying the Logger interface. */
|
|
17
|
+
const logger: Logger = {
|
|
18
|
+
info: () => {},
|
|
19
|
+
error: () => {},
|
|
20
|
+
warn: () => {},
|
|
21
|
+
debug: () => {},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Build an AuthService stub that authenticates to the given user. Only
|
|
25
|
+
* `authenticate` is exercised by the handler; the rest of the AuthService
|
|
26
|
+
* surface is irrelevant here, so we narrow through `unknown`. */
|
|
27
|
+
function authStub(user: AuthUser | undefined): AuthService {
|
|
28
|
+
const stub: Pick<AuthService, "authenticate"> = {
|
|
29
|
+
authenticate: async () => user,
|
|
30
|
+
};
|
|
31
|
+
return stub as unknown as AuthService;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A reader user holding the global `script-packages.read` rule. */
|
|
35
|
+
const readerUser: AuthUser = {
|
|
36
|
+
type: "application",
|
|
37
|
+
id: "app-1",
|
|
38
|
+
name: "tester",
|
|
39
|
+
accessRules: [scriptPackagesAccess.read.id],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function makeHandler(auth: AuthService) {
|
|
43
|
+
return createSdkTypesHttpHandler({
|
|
44
|
+
auth,
|
|
45
|
+
getReleaseVersion: () => RELEASE_VERSION,
|
|
46
|
+
getSdkBundle: () => BUNDLE,
|
|
47
|
+
logger,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function request(version: string, method = "GET"): Request {
|
|
52
|
+
const path = buildSdkTypesPath({ releaseVersion: version });
|
|
53
|
+
return new Request(`https://host/api/script-packages${path}`, { method });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("createSdkTypesHttpHandler", () => {
|
|
57
|
+
test("matching version returns 200 + immutable cache header + bundle", async () => {
|
|
58
|
+
const res = await makeHandler(authStub(readerUser))(
|
|
59
|
+
request(RELEASE_VERSION),
|
|
60
|
+
);
|
|
61
|
+
expect(res.status).toBe(200);
|
|
62
|
+
const cache = res.headers.get("Cache-Control") ?? "";
|
|
63
|
+
expect(cache).toContain("private");
|
|
64
|
+
expect(cache).toContain("immutable");
|
|
65
|
+
expect(cache).toContain("max-age=");
|
|
66
|
+
const body = (await res.json()) as {
|
|
67
|
+
files: { path: string; content: string }[];
|
|
68
|
+
};
|
|
69
|
+
expect(body.files).toHaveLength(1);
|
|
70
|
+
expect(body.files[0]?.path).toBe(SDK_BUNDLE_VIRTUAL_PATH);
|
|
71
|
+
expect(body.files[0]?.content).toBe(BUNDLE);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("mismatched version returns 409 (stale, not cached)", async () => {
|
|
75
|
+
const res = await makeHandler(authStub(readerUser))(request("0.0.1"));
|
|
76
|
+
expect(res.status).toBe(409);
|
|
77
|
+
expect(res.headers.get("Cache-Control")).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("unauthenticated returns 401", async () => {
|
|
81
|
+
const res = await makeHandler(authStub(undefined))(
|
|
82
|
+
request(RELEASE_VERSION),
|
|
83
|
+
);
|
|
84
|
+
expect(res.status).toBe(401);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("authenticated without read access returns 403", async () => {
|
|
88
|
+
const noAccess: AuthUser = {
|
|
89
|
+
type: "application",
|
|
90
|
+
id: "app-2",
|
|
91
|
+
name: "no-access",
|
|
92
|
+
accessRules: [],
|
|
93
|
+
};
|
|
94
|
+
const res = await makeHandler(authStub(noAccess))(request(RELEASE_VERSION));
|
|
95
|
+
expect(res.status).toBe(403);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("a service user is trusted (200)", async () => {
|
|
99
|
+
const service: AuthUser = { type: "service", pluginId: "svc" };
|
|
100
|
+
const res = await makeHandler(authStub(service))(request(RELEASE_VERSION));
|
|
101
|
+
expect(res.status).toBe(200);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("non-GET method returns 405", async () => {
|
|
105
|
+
const res = await makeHandler(authStub(readerUser))(
|
|
106
|
+
request(RELEASE_VERSION, "POST"),
|
|
107
|
+
);
|
|
108
|
+
expect(res.status).toBe(405);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("a wildcard access rule grants read", async () => {
|
|
112
|
+
const wildcard: AuthUser = {
|
|
113
|
+
type: "application",
|
|
114
|
+
id: "app-3",
|
|
115
|
+
name: "wild",
|
|
116
|
+
accessRules: ["*"],
|
|
117
|
+
};
|
|
118
|
+
const res = await makeHandler(authStub(wildcard))(request(RELEASE_VERSION));
|
|
119
|
+
expect(res.status).toBe(200);
|
|
120
|
+
});
|
|
121
|
+
});
|