diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index d66369a..09f2b6a 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -21,6 +21,8 @@ jobs: - uses: actions/checkout@v4 with: github-server-url: ${{ env.GITEA_INTERNAL_URL }} + # The function-runtime sandbox tests need bubblewrap; the runtime refuses to run functions without it. + - run: apt-get update -qq && apt-get install -y -qq --no-install-recommends bubblewrap >/dev/null && bwrap --version - run: npm ci - run: npm run check diff --git a/Dockerfile b/Dockerfile index 1be5182..fca7560 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,8 @@ LABEL org.opencontainers.image.title="homelab-upgrade" org.opencontainers.image. ENV BUILD_BRANCH=${BRANCH} BUILD_NUMBER=${BUILD_NUMBER} BUILD_REVISION=${REVISION} ENV NODE_ENV=production HOST=0.0.0.0 PORT=8080 DB_PATH=/data/queue.sqlite WEB_DIR=/app/dist/web WORKDIR /app +# bubblewrap for the function-runtime sidecar: without it the runtime refuses to run functions (no unsandboxed fallback). +RUN apt-get update && apt-get install -y --no-install-recommends bubblewrap && rm -rf /var/lib/apt/lists/* COPY package.json package-lock.json ./ RUN npm ci --omit=dev && npm cache clean --force COPY --from=build /app/dist/web ./dist/web diff --git a/TASK_STATE.md b/TASK_STATE.md index f2074ec..1a86211 100644 --- a/TASK_STATE.md +++ b/TASK_STATE.md @@ -26,6 +26,8 @@ Etapy 4-7 (`docs/IMPLEMENTATION.md`) zostały zaimplementowane równolegle przez - `src/infra/runtime/isolated-function-runtime.ts`: `FunctionTestExecutor.execute()` dla `typescript`/`posix`, katalogi efemeryczne, izolacja env, timeout+SIGKILL, limit stdout, maskowanie sekretów, `bwrap` gdy dostępny. - Sidecar `src/main/function-runtime.ts` (`/execute`, `/health`, `/audit`), klient HTTP `src/infra/runtime/function-runtime-client.ts`. +- Runtime jest **fail-closed**: bez `bwrap` zwraca `error` („sandbox unavailable”) zamiast odpalić funkcję jako zwykły proces (wcześniej cichy fallback — przez to CI dawało `leak`: obraz joba nie miał bubblewrap). CI instaluje `bubblewrap`, `Dockerfile` też. `/health` sidecara zgłasza `sandbox: unavailable`, gdy brak binarki. +- Znane ograniczenie prod: sidecar ma `cap_drop: ALL` + `no-new-privileges`, a VM103 `kernel.unprivileged_userns_clone=0`, więc `bwrap` nie stworzy namespace'u i `/execute` zwróci `error` (bezpiecznie, ale testowanie funkcji w UI nie działa). Naprawa wymaga decyzji właściciela (sysctl na VM103 albo szersze uprawnienia sidecara). - Izolacja jest **celowo niepełna** (decyzja właściciela 2026-09-22): `filesystemRead/Write` nieegzekwowane, sieć zależna od `bwrap`, brak twardego limitu pamięci dla `posix`. ### Etap 7 — API i edytor webowy: zaimplementowany diff --git a/deploy/runner/config.yaml b/deploy/runner/config.yaml index 189ff63..4424aa9 100644 --- a/deploy/runner/config.yaml +++ b/deploy/runner/config.yaml @@ -14,7 +14,9 @@ container: # Empty: the runner finds the daemon (/var/run/docker.sock) and mounts it into job containers, which is how `docker build` works in jobs. docker_host: "" force_pull: false - # SYS_ADMIN only: lets bwrap (function-runtime sandbox tests, stage 6) create unprivileged user namespaces inside the - # ephemeral job container. Without it, VM103's host kernel (kernel.unprivileged_userns_clone=0, Debian default) refuses - # them and those tests fail. Scoped to job containers only; the host and other containers are unaffected. - options: "--cap-add=SYS_ADMIN" + # Lets bwrap (function-runtime sandbox tests, stage 6) create unprivileged user namespaces inside the ephemeral job + # container: SYS_ADMIN bypasses VM103's host kernel restriction (kernel.unprivileged_userns_clone=0, Debian default); + # apparmor=unconfined because Docker's default AppArmor profile blocks the mount calls bwrap makes. Scoped to job + # containers only; the host and other containers are unaffected. (CI runs 105/106 failing with these options was not + # about them: the job image had no bwrap at all; ci.yaml now installs it.) + options: "--cap-add=SYS_ADMIN --security-opt apparmor=unconfined" diff --git a/src/infra/runtime/isolated-function-runtime.test.ts b/src/infra/runtime/isolated-function-runtime.test.ts index 8f5dc97..db58b67 100644 --- a/src/infra/runtime/isolated-function-runtime.test.ts +++ b/src/infra/runtime/isolated-function-runtime.test.ts @@ -75,6 +75,22 @@ EOF }); describe('Etap 6 Security & Sandbox Isolation', () => { + it('refuses to run anything when bubblewrap is missing (no unsandboxed fallback)', async () => { + const noSandbox = createIsolatedFunctionRuntime({ bwrapPath: '/nonexistent/bwrap' }); + expect(noSandbox.sandboxAvailable).toBe(false); + + const outcome = await noSandbox.execute({ + language: 'posix', + source: `printf '{"event":"ran","summary":"ran unsandboxed","data":{}}'`, + capabilities: { schemaVersion: 1, declarations: [] }, + input: {}, + }); + + expect(outcome.event).toBe('error'); + expect(outcome.summary).toContain('sandbox unavailable'); + expect(noSandbox.getAuditLog()[0]?.outcome.event).toBe('error'); + }); + it('blocks reading host /etc/passwd', async () => { const outcome = await runtime.execute({ language: 'typescript', diff --git a/src/infra/runtime/isolated-function-runtime.ts b/src/infra/runtime/isolated-function-runtime.ts index 72de5fa..63a661c 100644 --- a/src/infra/runtime/isolated-function-runtime.ts +++ b/src/infra/runtime/isolated-function-runtime.ts @@ -63,6 +63,8 @@ const SAFE_BASE_ENV = ['PATH', 'LANG', 'TZ']; export interface IsolatedFunctionRuntime extends FunctionTestExecutor { execute(request: FunctionExecutionRequest): Promise; + /** False when bubblewrap is missing: every execute() then returns an `error` outcome without running anything. */ + readonly sandboxAvailable: boolean; getAuditLog(): readonly FunctionExecutionAudit[]; } @@ -90,6 +92,11 @@ export function createIsolatedFunctionRuntime( let proxyPort = 0; try { + // Fail closed: untrusted code never runs as a plain process when the sandbox is missing. + if (!hasBwrap) { + throw new Error(`sandbox unavailable: bubblewrap not found at ${bwrapBin}; refusing to run the function unsandboxed`); + } + // 1. Prepare environment variables from capabilities const env: Record = {}; for (const key of SAFE_BASE_ENV) { @@ -201,44 +208,38 @@ run(); args = ['-eu', './script.sh']; } - // 4. Wrap with bwrap sandbox if bubblewrap is available - let spawnCommand = command; - let spawnArgs = args; - let spawnCwd = tempDir; + // 4. Wrap with the bwrap sandbox (hasBwrap was checked above: no unsandboxed fallback) + const nodeDir = dirname(dirname(process.execPath)); + const bwrapOptions: string[] = [ + '--unshare-all', // unshare user, mount, pid, ipc, net, uts + '--ro-bind', '/usr', '/usr', + '--ro-bind', '/lib', '/lib', + '--ro-bind-try', '/lib64', '/lib64', + '--ro-bind', '/bin', '/bin', + '--ro-bind-try', '/sbin', '/sbin', + '--ro-bind-try', nodeDir, nodeDir, + '--proc', '/proc', + '--dev', '/dev', + '--tmpfs', '/tmp', + '--dir', '/workspace', + '--bind', tempDir, '/workspace', + '--chdir', '/workspace', + ]; - if (hasBwrap) { - const nodeDir = dirname(dirname(process.execPath)); - spawnCommand = bwrapBin; - const bwrapOptions: string[] = [ - '--unshare-all', // unshare user, mount, pid, ipc, net, uts - '--ro-bind', '/usr', '/usr', - '--ro-bind', '/lib', '/lib', - '--ro-bind-try', '/lib64', '/lib64', - '--ro-bind', '/bin', '/bin', - '--ro-bind-try', '/sbin', '/sbin', - '--ro-bind-try', nodeDir, nodeDir, - '--proc', '/proc', - '--dev', '/dev', - '--tmpfs', '/tmp', - '--dir', '/workspace', - '--bind', tempDir, '/workspace', - '--chdir', '/workspace', - ]; - - // Share network only if proxy broker is listening - if (proxyPort > 0) { - bwrapOptions.push('--share-net'); - } - - // Pass only declared env vars - for (const [k, v] of Object.entries(env)) { - bwrapOptions.push('--setenv', k, v); - } - - spawnArgs = [...bwrapOptions, command, ...args]; - spawnCwd = tempDir; + // Share network only if proxy broker is listening + if (proxyPort > 0) { + bwrapOptions.push('--share-net'); } + // Pass only declared env vars + for (const [k, v] of Object.entries(env)) { + bwrapOptions.push('--setenv', k, v); + } + + const spawnCommand = bwrapBin; + const spawnArgs = [...bwrapOptions, command, ...args]; + const spawnCwd = tempDir; + // 5. Spawn child process group with detached = true and enforce resource limits const outcomeJson = await new Promise((resolve, reject) => { let killTimer: NodeJS.Timeout | undefined; @@ -401,7 +402,7 @@ run(); maxOutputBytes: maxOutput, memoryMb, }, - sandboxVersion: hasBwrap ? 'bwrap-namespace' : 'isolated-process', + sandboxVersion: 'bwrap-namespace', outcome: parsedOutcome, timedOut, truncated, @@ -428,7 +429,7 @@ run(); maxOutputBytes: maxOutput, memoryMb, }, - sandboxVersion: hasBwrap ? 'bwrap-namespace' : 'isolated-process', + sandboxVersion: 'bwrap-namespace', outcome: errorOutcome, timedOut, truncated, @@ -446,6 +447,7 @@ run(); return { execute, + sandboxAvailable: hasBwrap, getAuditLog() { return [...auditLog]; }, diff --git a/src/main/function-runtime.ts b/src/main/function-runtime.ts index 808a8cf..4bb79b9 100644 --- a/src/main/function-runtime.ts +++ b/src/main/function-runtime.ts @@ -14,7 +14,7 @@ const runtime = createIsolatedFunctionRuntime(); const app = Fastify({ logger: true }); app.get('/health', (_req, reply) => { - return reply.send({ ok: true, sandbox: 'bubblewrap' }); + return reply.send({ ok: true, sandbox: runtime.sandboxAvailable ? 'bubblewrap' : 'unavailable' }); }); app.get('/audit', (_req, reply) => {