From ded79dafd0041b491f1a091076570bcee491fa5f Mon Sep 17 00:00:00 2001 From: Thanh Nguyen Date: Wed, 26 Aug 2026 22:21:41 +0700 Subject: [PATCH 1/3] feat(runtime): add native file generation support --- .github/workflows/ci.yml | 3 + api/Dockerfile | 5 + api/bun.lock | 4 +- api/config/sandbox.cfg | 7 + api/package.json | 2 +- api/src/nsjail.test.ts | 23 ++ api/src/nsjail.ts | 5 + apparmor/sandbox-nsjail | 12 + build-packages.sh | 123 ++++++++- docker/package-init.sh | 230 +++++++++++++++- javascript-packages.txt | 8 +- scripts/verify-file-generation-runtime.py | 312 ++++++++++++++++++++++ tests/file_generation_runtime_static.sh | 58 ++++ 13 files changed, 770 insertions(+), 22 deletions(-) mode change 100644 => 100755 docker/package-init.sh create mode 100755 scripts/verify-file-generation-runtime.py create mode 100755 tests/file_generation_runtime_static.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94e2286..53de417 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,9 @@ jobs: - name: Sandbox-runner liveness checks run: tests/sandbox_runner_healthcheck.sh + - name: File-generation runtime checks + run: tests/file_generation_runtime_static.sh + - name: Validate sandbox Dockerfiles run: | docker buildx build --check -f api/Dockerfile . diff --git a/api/Dockerfile b/api/Dockerfile index f8d6713..93ad5ac 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -82,6 +82,11 @@ RUN apt-get update && \ file \ jq \ poppler-utils \ + libreoffice \ + ffmpeg \ + libpango-1.0-0 \ + libpangoft2-1.0-0 \ + shared-mime-info \ fonts-dejavu \ fonts-liberation \ libnl-route-3-200 \ diff --git a/api/bun.lock b/api/bun.lock index 2c714c0..48515e3 100644 --- a/api/bun.lock +++ b/api/bun.lock @@ -11,7 +11,7 @@ "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0", "express": "^4.22.2", - "nanoid": "^3.3.7", + "nanoid": "^3.3.18", "pino": "^10.3.0", "prom-client": "^15.1.3", "semver": "^7.8.0", @@ -168,7 +168,7 @@ "ms": ["ms@2.0.0", "", {}, "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="], - "nanoid": ["nanoid@3.3.8", "", { "bin": "bin/nanoid.cjs" }, "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w=="], + "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], diff --git a/api/config/sandbox.cfg b/api/config/sandbox.cfg index b9f7ce6..d25b8bd 100644 --- a/api/config/sandbox.cfg +++ b/api/config/sandbox.cfg @@ -52,6 +52,13 @@ mount { mandatory: false } +# Native document/media tools need their read-only package registries and +# font configuration. Keep the allowlist narrow instead of exposing /etc. +mount { src: "/etc/alternatives" dst: "/etc/alternatives" is_bind: true rw: false mandatory: false } +mount { src: "/etc/fonts" dst: "/etc/fonts" is_bind: true rw: false mandatory: false } +mount { src: "/etc/libreoffice" dst: "/etc/libreoffice" is_bind: true rw: false mandatory: false } +mount { src: "/var/cache/fontconfig" dst: "/var/cache/fontconfig" is_bind: true rw: false mandatory: false } + # Static /etc/hosts so "localhost" resolves for tool-call-server connections. # Uses inline content instead of bind-mounting the host's /etc/hosts, which # in Kubernetes leaks pod name, internal IP, and deployment metadata. diff --git a/api/package.json b/api/package.json index b36ff5f..5143b61 100644 --- a/api/package.json +++ b/api/package.json @@ -16,7 +16,7 @@ "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0", "express": "^4.22.2", - "nanoid": "^3.3.7", + "nanoid": "^3.3.18", "pino": "^10.3.0", "prom-client": "^15.1.3", "semver": "^7.8.0" diff --git a/api/src/nsjail.test.ts b/api/src/nsjail.test.ts index ee6fbbc..549529a 100644 --- a/api/src/nsjail.test.ts +++ b/api/src/nsjail.test.ts @@ -91,6 +91,29 @@ describe('NsJail args', () => { expect(valueAfter(args, '--config')).toBe('/tmp/nsjail-job-xyz.cfg'); }); + test('exposes only the package bundle checksum alongside the selected runtime', async () => { + const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'nsjail-bundle-checksum-')); + const originalPackagesDirectory = config.packages_directory; + config.packages_directory = tmp; + try { + const checksum = path.join(tmp, '.bundle.sha256'); + await fsp.writeFile(checksum, '0'.repeat(64)); + const args = buildArgs({ + logPath: '/tmp/nsjail-test.log', + pkgdir: path.join(tmp, 'python/3.14.4'), + timeout: 1000, + memoryLimit: -1, + envVars: {}, + command: ['/bin/bash', path.join(tmp, 'python/3.14.4/run'), 'main.py'], + identity: { slot: 0, uid: 65534, gid: 65534, perJobUid: false }, + }); + expect(hasArgPair(args, '-R', `${checksum}:${checksum}`)).toBe(true); + } finally { + config.packages_directory = originalPackagesDirectory; + await fsp.rm(tmp, { recursive: true, force: true }); + } + }); + test('does not export TOOL_CALL_SOCKET into the jail (preamble references the literal path)', () => { const args = buildArgs({ logPath: '/tmp/nsjail-test.log', diff --git a/api/src/nsjail.ts b/api/src/nsjail.ts index 87a2b79..fc686c1 100644 --- a/api/src/nsjail.ts +++ b/api/src/nsjail.ts @@ -668,6 +668,11 @@ export function buildArgs(opts: BuildArgsOptions): string[] { '-R', `${pkgdir}:${pkgdir}`, ]; + const bundleChecksum = path.join(config.packages_directory, '.bundle.sha256'); + if (fs.existsSync(bundleChecksum)) { + args.push('-R', `${bundleChecksum}:${bundleChecksum}`); + } + if (config.use_cgroupv2) { args.push('--use_cgroupv2'); } diff --git a/apparmor/sandbox-nsjail b/apparmor/sandbox-nsjail index 7e8239e..607cfd3 100644 --- a/apparmor/sandbox-nsjail +++ b/apparmor/sandbox-nsjail @@ -45,8 +45,20 @@ profile sandbox-nsjail flags=(attach_disconnected,mediate_deleted) { # Full access to sandbox working directories /pkgs/** rwlk, + /host-packages/ r, + /host-packages/** r, /tmp/** rwlk, /mnt/** rwlk, + + # Read-only registries required by native document and media tools + /etc/alternatives/ r, + /etc/alternatives/** r, + /etc/fonts/ r, + /etc/fonts/** r, + /etc/libreoffice/ r, + /etc/libreoffice/** r, + /var/cache/fontconfig/ r, + /var/cache/fontconfig/** r, # NsJail binary and config /usr/sbin/nsjail rix, diff --git a/build-packages.sh b/build-packages.sh index 2ae04d0..cb3264a 100755 --- a/build-packages.sh +++ b/build-packages.sh @@ -10,8 +10,14 @@ # Environment Variables: # PYTHON_VERSION=3.14.4 # Python version to install # NODE_VERSION=24.15.0 # Node.js version to install +# NPM_VERSION=12.0.2 # npm version bundled into the Node runtime +# NPM_TAR_VERSION=7.5.21 # Patched tar package embedded in npm +# NPM_BRACE_EXPANSION_VERSION=5.0.9 # Patched brace-expansion embedded in npm +# NPM_IP_ADDRESS_VERSION=10.3.1 # Patched ip-address embedded in npm +# NPM_INSTALL_ATTEMPTS=3 # Retry the patched npm install # BUN_VERSION=1.3.14 # Bun version to install # BASH_PACKAGE_VERSION=5.2.0 # Bash package registration version (semver-like x.y.z) +# PYTHON_PACKAGE_INSTALL_ATTEMPTS=3 # Retry the complete Python dependency install # SKIP_PYTHON_PACKAGES=1 # Skip Python pip packages # SKIP_JS_PACKAGES=1 # Skip JavaScript npm packages for Node/Bun # SKIP_NODE=1 # Skip Node.js installation @@ -28,6 +34,11 @@ cd "$SCRIPT_DIR" PYTHON_VERSION="${PYTHON_VERSION:-3.14.4}" NODE_VERSION="${NODE_VERSION:-24.15.0}" +NPM_VERSION="${NPM_VERSION:-12.0.2}" +NPM_TAR_VERSION="${NPM_TAR_VERSION:-7.5.21}" +NPM_BRACE_EXPANSION_VERSION="${NPM_BRACE_EXPANSION_VERSION:-5.0.9}" +NPM_IP_ADDRESS_VERSION="${NPM_IP_ADDRESS_VERSION:-10.3.1}" +NPM_INSTALL_ATTEMPTS="${NPM_INSTALL_ATTEMPTS:-3}" BUN_VERSION="${BUN_VERSION:-1.3.14}" PACKAGES_DIR="./data/pkgs" JS_PACKAGE_MANIFEST="${JS_PACKAGE_MANIFEST:-${SCRIPT_DIR}/javascript-packages.txt}" @@ -109,7 +120,8 @@ install_python() { mkdir -p ${pkg_dest} rm -f ${pkg_dest}/.package-installed cd /tmp - wget -q https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tar.xz + wget -q --tries=5 --timeout=30 --retry-connrefused \ + https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tar.xz tar xf Python-${PYTHON_VERSION}.tar.xz cd Python-${PYTHON_VERSION} ./configure --prefix=${pkg_dest} --enable-optimizations 2>/dev/null @@ -163,7 +175,14 @@ install_python_packages() { # onnxruntime wheel segfaults under NsJail. 0.0.2 still supports PPTX via # python-pptx without that native dependency. local python_packages_installed=false - if docker exec "$CONTAINER_NAME" "$pip_path" install \ + local install_attempts="${PYTHON_PACKAGE_INSTALL_ATTEMPTS:-3}" + if [[ ! "$install_attempts" =~ ^[1-9][0-9]*$ ]]; then + echo "ERROR: PYTHON_PACKAGE_INSTALL_ATTEMPTS must be a positive integer" >&2 + return 1 + fi + + for ((attempt = 1; attempt <= install_attempts; attempt++)); do + if docker exec "$CONTAINER_NAME" "$pip_path" install --retries 10 --timeout 60 \ openpyxl \ matplotlib \ numpy \ @@ -177,7 +196,12 @@ install_python_packages() { networkx \ sympy \ wordcloud \ + pypdf \ pypdf2 \ + pdfplumber \ + "weasyprint>=68" \ + "msgpack>=1.2.1" \ + "setuptools>=78.1.1" \ python-docx \ imageio \ seaborn \ @@ -218,14 +242,25 @@ install_python_packages() { osmnx \ folium \ gpxpy; then - python_packages_installed=true - else + python_packages_installed=true + break + fi + + if [ "$attempt" -lt "$install_attempts" ]; then + echo "Python package installation attempt ${attempt} failed; retrying..." + sleep $((attempt * 5)) + fi + done + + if [ "$python_packages_installed" != true ]; then echo "ERROR: Python package installation failed" return 1 fi docker exec "$CONTAINER_NAME" "$pip_path" install --upgrade six 2>/dev/null || true if [ "$python_packages_installed" = true ]; then + docker exec "$CONTAINER_NAME" "/pkgs/python/${PYTHON_VERSION}/bin/python3" -c \ + 'from pathlib import Path; import pip; (Path(pip.__file__).resolve().parent / "_vendor" / "bom.cdx.json").unlink(missing_ok=True)' docker exec "$CONTAINER_NAME" bash -c "echo \$(date +%s)000 > /pkgs/python/${PYTHON_VERSION}/.package-installed" fi @@ -250,7 +285,8 @@ install_node() { mkdir -p ${pkg_dest} rm -f ${pkg_dest}/.package-installed cd /tmp - curl -fsSL -o node.tar.xz https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz + curl -fsSL --retry 5 --retry-delay 2 --retry-all-errors --connect-timeout 20 \ + -o node.tar.xz https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz tar -xJf node.tar.xz --strip-components=1 -C ${pkg_dest} rm -f node.tar.xz " @@ -280,11 +316,72 @@ chmod +x ${pkg_dest}/run" echo 'NODE_PATH=${pkg_dest}/node_modules' >> ${pkg_dest}/.env " + if [[ ! "$NPM_INSTALL_ATTEMPTS" =~ ^[1-9][0-9]*$ ]]; then + echo "ERROR: NPM_INSTALL_ATTEMPTS must be a positive integer" >&2 + return 1 + fi + + local npm_installed=false + for ((attempt = 1; attempt <= NPM_INSTALL_ATTEMPTS; attempt++)); do + if docker exec "$CONTAINER_NAME" bash -c " + set -e + export PATH=${pkg_dest}/bin:\$PATH + export npm_config_fetch_retries=5 + export npm_config_fetch_retry_maxtimeout=60000 + export npm_config_fetch_timeout=60000 + ${pkg_dest}/bin/npm install --global \ + --prefix ${pkg_dest} \ + --no-audit \ + --no-fund \ + npm@${NPM_VERSION} + patch_npm_dependency() { + package_name=\"\$1\" + package_version=\"\$2\" + package_root=${pkg_dest}/lib/node_modules/npm/node_modules/\"\$package_name\" + patch_dir=\$(mktemp -d) + ${pkg_dest}/bin/npm pack --silent \ + --pack-destination \"\$patch_dir\" \ + \"\$package_name@\$package_version\" >/dev/null + package_archive=\$(find \"\$patch_dir\" -maxdepth 1 -type f -name '*.tgz' -print -quit) + test -f \"\$package_archive\" + rm -rf \"\$package_root\" + mkdir -p \"\$package_root\" + tar -xzf \"\$package_archive\" --strip-components=1 -C \"\$package_root\" + rm -rf \"\$patch_dir\" + } + patch_npm_dependency tar ${NPM_TAR_VERSION} + patch_npm_dependency brace-expansion ${NPM_BRACE_EXPANSION_VERSION} + patch_npm_dependency ip-address ${NPM_IP_ADDRESS_VERSION} + test \"\$(${pkg_dest}/bin/npm --version)\" = ${NPM_VERSION} + test \"\$(${pkg_dest}/bin/node -p \\ + \"require('${pkg_dest}/lib/node_modules/npm/node_modules/tar/package.json').version\")\" \ + = ${NPM_TAR_VERSION} + test \"\$(${pkg_dest}/bin/node -p \\ + \"require('${pkg_dest}/lib/node_modules/npm/node_modules/brace-expansion/package.json').version\")\" \ + = ${NPM_BRACE_EXPANSION_VERSION} + test \"\$(${pkg_dest}/bin/node -p \\ + \"require('${pkg_dest}/lib/node_modules/npm/node_modules/ip-address/package.json').version\")\" \ + = ${NPM_IP_ADDRESS_VERSION} + "; then + npm_installed=true + break + fi + if [ "$attempt" -lt "$NPM_INSTALL_ATTEMPTS" ]; then + echo "Patched npm installation attempt ${attempt} failed; retrying..." + sleep $((attempt * 5)) + fi + done + if [ "$npm_installed" != true ]; then + echo "ERROR: Failed to install the patched npm runtime" >&2 + return 1 + fi + if [ "${SKIP_JS_PACKAGES:-}" = "1" ]; then docker exec "$CONTAINER_NAME" bash -c "echo \$(date +%s)000 > ${pkg_dest}/.package-installed" fi echo "Node.js ${NODE_VERSION} installed: $(docker exec "$CONTAINER_NAME" "${pkg_dest}/bin/node" --version)" + echo "npm ${NPM_VERSION} installed with tar ${NPM_TAR_VERSION}, brace-expansion ${NPM_BRACE_EXPANSION_VERSION}, and ip-address ${NPM_IP_ADDRESS_VERSION}" } install_node_packages() { @@ -347,7 +444,8 @@ install_bun() { mkdir -p ${pkg_dest} rm -f ${pkg_dest}/.package-installed cd /tmp - curl -fsSL -o bun.zip https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-${BUN_ARCH}.zip + curl -fsSL --retry 5 --retry-delay 2 --retry-all-errors --connect-timeout 20 \ + -o bun.zip https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-${BUN_ARCH}.zip unzip -o bun.zip mv bun-linux-${BUN_ARCH}/bun ${pkg_dest}/bun chmod +x ${pkg_dest}/bun @@ -494,6 +592,18 @@ EOF echo "Bash ${bash_package_version} registered (using system binary ${system_bash_version})" } +write_bundle_checksum() { + docker exec "$CONTAINER_NAME" bash -c ' + set -euo pipefail + checksum="$(find /pkgs -type f ! -name .bundle.sha256 -print0 \ + | sort -z \ + | xargs -0 sha256sum \ + | sha256sum \ + | cut -d " " -f 1)" + printf "%s\n" "$checksum" > /pkgs/.bundle.sha256 + ' +} + main() { echo "==============================================" echo " Code Interpreter API - Package Builder" @@ -528,6 +638,7 @@ main() { install_bun install_bun_packages install_bash + write_bundle_checksum echo "Setting permissions..." chmod -R a+rX "$PACKAGES_DIR" 2>/dev/null || true diff --git a/docker/package-init.sh b/docker/package-init.sh old mode 100644 new mode 100755 index 60855b9..db94ebe --- a/docker/package-init.sh +++ b/docker/package-init.sh @@ -19,8 +19,14 @@ PYTHON_ALIAS="python${PYTHON_SITE_VERSION}" # between installers) for packages with no version constraint in the list # below — that's inherent to installing unpinned packages, not specific to uv. PYTHON_PACKAGE_INSTALLER="${PYTHON_PACKAGE_INSTALLER:-uv}" +PYTHON_PACKAGE_INSTALL_ATTEMPTS="${PYTHON_PACKAGE_INSTALL_ATTEMPTS:-3}" UV_VERSION="${UV_VERSION:-0.11.26}" NODE_VERSION="${NODE_VERSION:-24.15.0}" +NPM_VERSION="${NPM_VERSION:-12.0.2}" +NPM_TAR_VERSION="${NPM_TAR_VERSION:-7.5.21}" +NPM_BRACE_EXPANSION_VERSION="${NPM_BRACE_EXPANSION_VERSION:-5.0.9}" +NPM_IP_ADDRESS_VERSION="${NPM_IP_ADDRESS_VERSION:-10.3.1}" +NPM_INSTALL_ATTEMPTS="${NPM_INSTALL_ATTEMPTS:-3}" BUN_VERSION="${BUN_VERSION:-1.3.14}" BASH_PACKAGE_VERSION="${BASH_PACKAGE_VERSION:-5.2.0}" INSTALL_FAILED=false @@ -79,6 +85,141 @@ js_packages_ready() { done } +python_security_baseline_ready() { + local python_path="/pkgs/python/${PYTHON_VERSION}/bin/python3" + local pip_vendor_sbom + [ -x "$python_path" ] || return 1 + "$python_path" - <<'PY' >/dev/null 2>&1 +from importlib import metadata +from packaging.version import Version + +assert Version(metadata.version("msgpack")) >= Version("1.2.1") +assert Version(metadata.version("setuptools")) >= Version("78.1.1") +PY + pip_vendor_sbom="$(pip_vendor_sbom_path "$python_path" 2>/dev/null)" || return 1 + [ ! -e "$pip_vendor_sbom" ] +} + +pip_vendor_sbom_path() { + local python_path="$1" + "$python_path" - <<'PY' +from pathlib import Path +import pip + +print(Path(pip.__file__).resolve().parent / "_vendor" / "bom.cdx.json") +PY +} + +remove_nonruntime_pip_sbom() { + local python_path="$1" + local pip_vendor_sbom + + pip_vendor_sbom="$(pip_vendor_sbom_path "$python_path")" || return 1 + if [ -f "$pip_vendor_sbom" ]; then + # pip ships a build-time SBOM that scanners can misread as installed packages. + rm -f "$pip_vendor_sbom" + fi +} + +npm_runtime_ready() { + local node_root="/pkgs/node/${NODE_VERSION}" + local actual_npm_version + local actual_tar_version + local actual_brace_expansion_version + local actual_ip_address_version + + [ -x "${node_root}/bin/npm" ] || return 1 + actual_npm_version="$(PATH="${node_root}/bin:$PATH" \ + "${node_root}/bin/npm" --version 2>/dev/null || true)" + actual_tar_version="$("${node_root}/bin/node" -p \ + "require('${node_root}/lib/node_modules/npm/node_modules/tar/package.json').version" \ + 2>/dev/null || true)" + actual_brace_expansion_version="$("${node_root}/bin/node" -p \ + "require('${node_root}/lib/node_modules/npm/node_modules/brace-expansion/package.json').version" \ + 2>/dev/null || true)" + actual_ip_address_version="$("${node_root}/bin/node" -p \ + "require('${node_root}/lib/node_modules/npm/node_modules/ip-address/package.json').version" \ + 2>/dev/null || true)" + if [ "$actual_npm_version" = "$NPM_VERSION" ] && + [ "$actual_tar_version" = "$NPM_TAR_VERSION" ] && + [ "$actual_brace_expansion_version" = "$NPM_BRACE_EXPANSION_VERSION" ] && + [ "$actual_ip_address_version" = "$NPM_IP_ADDRESS_VERSION" ]; then + return 0 + fi + + echo "npm runtime mismatch: expected npm ${NPM_VERSION}/tar ${NPM_TAR_VERSION}/brace-expansion ${NPM_BRACE_EXPANSION_VERSION}/ip-address ${NPM_IP_ADDRESS_VERSION}, got npm ${actual_npm_version:-missing}/tar ${actual_tar_version:-missing}/brace-expansion ${actual_brace_expansion_version:-missing}/ip-address ${actual_ip_address_version:-missing}" >&2 + return 1 +} + +install_npm_dependency_patch() { + local node_root="$1" + local package_name="$2" + local package_version="$3" + local package_root="${node_root}/lib/node_modules/npm/node_modules/${package_name}" + local patch_dir + local package_archive + + patch_dir="$(mktemp -d)" + if ! PATH="${node_root}/bin:$PATH" \ + npm_config_fetch_retries=5 \ + npm_config_fetch_retry_maxtimeout=60000 \ + npm_config_fetch_timeout=60000 \ + "${node_root}/bin/npm" pack --silent \ + --pack-destination "$patch_dir" "${package_name}@${package_version}" >/dev/null; then + echo "npm ${package_name} ${package_version} download failed" >&2 + rm -rf "$patch_dir" + return 1 + fi + + package_archive="$(find "$patch_dir" -maxdepth 1 -type f -name '*.tgz' -print -quit)" + if [ ! -f "$package_archive" ]; then + echo "npm ${package_name} ${package_version} archive is missing" >&2 + rm -rf "$patch_dir" + return 1 + fi + + rm -rf "$package_root" + mkdir -p "$package_root" + if ! tar -xzf "$package_archive" --strip-components=1 -C "$package_root"; then + echo "npm ${package_name} ${package_version} extraction failed" >&2 + rm -rf "$patch_dir" + return 1 + fi + rm -rf "$patch_dir" +} + +install_secure_npm() { + local node_root="$1" + local npm_path="${node_root}/bin/npm" + + if ! PATH="${node_root}/bin:$PATH" \ + npm_config_fetch_retries=5 \ + npm_config_fetch_retry_maxtimeout=60000 \ + npm_config_fetch_timeout=60000 \ + "$npm_path" install --global \ + --prefix "$node_root" \ + --no-audit \ + --no-fund \ + "npm@${NPM_VERSION}"; then + echo "npm ${NPM_VERSION} installation failed" >&2 + return 1 + fi + + # Patch vulnerable dependencies bundled by npm until an npm release carries them. + if ! install_npm_dependency_patch "$node_root" tar "$NPM_TAR_VERSION"; then + return 1 + fi + if ! install_npm_dependency_patch \ + "$node_root" brace-expansion "$NPM_BRACE_EXPANSION_VERSION"; then + return 1 + fi + if ! install_npm_dependency_patch \ + "$node_root" ip-address "$NPM_IP_ADDRESS_VERSION"; then + return 1 + fi + npm_runtime_ready +} + load_js_packages echo "==============================================" @@ -87,13 +228,19 @@ echo "==============================================" echo "" packages_ready() { + [ -f "/pkgs/.bundle.sha256" ] && [ -f "/pkgs/python/${PYTHON_VERSION}/.package-installed" ] && [ -d "/pkgs/python/${PYTHON_VERSION}/lib/python${PYTHON_SITE_VERSION}/site-packages/PIL" ] && [ -d "/pkgs/python/${PYTHON_VERSION}/lib/python${PYTHON_SITE_VERSION}/site-packages/markitdown" ] && [ -d "/pkgs/python/${PYTHON_VERSION}/lib/python${PYTHON_SITE_VERSION}/site-packages/chdb" ] && [ -d "/pkgs/python/${PYTHON_VERSION}/lib/python${PYTHON_SITE_VERSION}/site-packages/statsmodels" ] && + [ -d "/pkgs/python/${PYTHON_VERSION}/lib/python${PYTHON_SITE_VERSION}/site-packages/pypdf" ] && + [ -d "/pkgs/python/${PYTHON_VERSION}/lib/python${PYTHON_SITE_VERSION}/site-packages/pdfplumber" ] && + [ -d "/pkgs/python/${PYTHON_VERSION}/lib/python${PYTHON_SITE_VERSION}/site-packages/weasyprint" ] && [ -d "/pkgs/python/${PYTHON_VERSION}/lib/python${PYTHON_SITE_VERSION}/site-packages/rasterio" ] && + python_security_baseline_ready && [ -f "/pkgs/node/${NODE_VERSION}/.package-installed" ] && + npm_runtime_ready && js_packages_ready "/pkgs/node/${NODE_VERSION}" && [ -f "/pkgs/bun/${BUN_VERSION}/.package-installed" ] && js_packages_ready "/pkgs/bun/${BUN_VERSION}" && @@ -132,7 +279,8 @@ mkdir -p "$PKG_DEST" rm -f "$PKG_DEST/.package-installed" cd /tmp -wget -q "https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tar.xz" +wget -q --tries=5 --timeout=30 --retry-connrefused \ + "https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tar.xz" tar xf "Python-${PYTHON_VERSION}.tar.xz" cd "Python-${PYTHON_VERSION}" ./configure --prefix="$PKG_DEST" --enable-optimizations 2>/dev/null @@ -181,10 +329,17 @@ if [ -f "$PIP_PATH" ]; then PYTHON_INSTALL_CMD=("${PKG_DEST}/bin/uv" pip install --python "${PKG_DEST}/bin/python3") fi + if [[ ! "$PYTHON_PACKAGE_INSTALL_ATTEMPTS" =~ ^[1-9][0-9]*$ ]]; then + echo "ERROR: PYTHON_PACKAGE_INSTALL_ATTEMPTS must be a positive integer" >&2 + INSTALL_FAILED=true + PYTHON_PACKAGE_INSTALL_ATTEMPTS=1 + fi + # MarkItDown 0.1.x initializes Magika/ONNX at import time; the aarch64 # onnxruntime wheel segfaults under NsJail. 0.0.2 still supports PPTX via # python-pptx without that native dependency. - if ! "${PYTHON_INSTALL_CMD[@]}" \ + for ((attempt = 1; attempt <= PYTHON_PACKAGE_INSTALL_ATTEMPTS; attempt++)); do + if env UV_HTTP_RETRIES=10 UV_HTTP_TIMEOUT=60 "${PYTHON_INSTALL_CMD[@]}" \ openpyxl \ matplotlib \ numpy \ @@ -198,7 +353,12 @@ if [ -f "$PIP_PATH" ]; then networkx \ sympy \ wordcloud \ + pypdf \ pypdf2 \ + pdfplumber \ + "weasyprint>=68" \ + "msgpack>=1.2.1" \ + "setuptools>=78.1.1" \ python-docx \ imageio \ seaborn \ @@ -239,15 +399,29 @@ if [ -f "$PIP_PATH" ]; then osmnx \ folium \ gpxpy; then + PYTHON_PACKAGES_INSTALLED=true + break + fi + + if [ "$attempt" -lt "$PYTHON_PACKAGE_INSTALL_ATTEMPTS" ]; then + echo "Python package installation attempt ${attempt} failed; retrying..." + sleep $((attempt * 5)) + fi + done + + if [ "$PYTHON_PACKAGES_INSTALLED" != true ]; then echo "ERROR: Python package installation failed" INSTALL_FAILED=true - else - PYTHON_PACKAGES_INSTALLED=true fi "$PIP_PATH" install --upgrade six 2>/dev/null || true if [ "$PYTHON_PACKAGES_INSTALLED" = true ]; then - echo "$(date +%s)000" > "$PKG_DEST/.package-installed" + if remove_nonruntime_pip_sbom "${PKG_DEST}/bin/python3"; then + echo "$(date +%s)000" > "$PKG_DEST/.package-installed" + else + echo "ERROR: Failed to remove pip's non-runtime SBOM" >&2 + INSTALL_FAILED=true + fi fi echo "" @@ -286,7 +460,8 @@ esac if [ -n "$NODE_ARCH" ]; then NODE_URL="https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" cd /tmp - if curl -fsSL "$NODE_URL" -o node.tar.xz; then + if curl -fsSL --retry 5 --retry-delay 2 --retry-all-errors --connect-timeout 20 \ + "$NODE_URL" -o node.tar.xz; then if tar -xJf node.tar.xz --strip-components=1 -C "$NODE_DEST"; then rm -f node.tar.xz @@ -315,8 +490,32 @@ EOF echo "NODE_PATH=${NODE_DEST}/node_modules" } > "$NODE_DEST/.env" - NODE_INSTALLED=true - echo "Node.js ${NODE_VERSION} installed: $($NODE_DEST/bin/node --version)" + if [[ ! "$NPM_INSTALL_ATTEMPTS" =~ ^[1-9][0-9]*$ ]]; then + echo "ERROR: NPM_INSTALL_ATTEMPTS must be a positive integer" >&2 + INSTALL_FAILED=true + NPM_INSTALL_ATTEMPTS=1 + fi + + NPM_INSTALLED=false + for ((attempt = 1; attempt <= NPM_INSTALL_ATTEMPTS; attempt++)); do + if install_secure_npm "$NODE_DEST"; then + NPM_INSTALLED=true + break + fi + if [ "$attempt" -lt "$NPM_INSTALL_ATTEMPTS" ]; then + echo "Patched npm installation attempt ${attempt} failed; retrying..." + sleep $((attempt * 5)) + fi + done + + if [ "$NPM_INSTALLED" = true ]; then + NODE_INSTALLED=true + echo "Node.js ${NODE_VERSION} installed: $($NODE_DEST/bin/node --version)" + echo "npm ${NPM_VERSION} installed with tar ${NPM_TAR_VERSION}" + else + echo "ERROR: Failed to install the patched npm runtime" + INSTALL_FAILED=true + fi else echo "ERROR: Failed to extract Node.js archive" rm -f node.tar.xz @@ -394,7 +593,8 @@ esac if [ -n "$BUN_ARCH" ]; then BUN_URL="https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-${BUN_ARCH}.zip" cd /tmp - if curl -fsSL "$BUN_URL" -o bun.zip; then + if curl -fsSL --retry 5 --retry-delay 2 --retry-all-errors --connect-timeout 20 \ + "$BUN_URL" -o bun.zip; then if unzip -o bun.zip && mv bun-linux-${BUN_ARCH}/bun "$BUN_DEST/"; then chmod +x "$BUN_DEST/bun" rm -rf bun.zip bun-linux-${BUN_ARCH} @@ -554,10 +754,22 @@ cat > "$MARKER_FILE" << MARKER initialized_at=$(date -Iseconds) python_version=${PYTHON_VERSION} node_version=${NODE_VERSION} +npm_version=${NPM_VERSION} +npm_tar_version=${NPM_TAR_VERSION} +npm_brace_expansion_version=${NPM_BRACE_EXPANSION_VERSION} +npm_ip_address_version=${NPM_IP_ADDRESS_VERSION} bun_version=${BUN_VERSION} packages=$(ls /pkgs/ 2>/dev/null | tr '\n' ',') MARKER +echo "Calculating package bundle checksum..." +BUNDLE_CHECKSUM="$(find /pkgs -type f ! -name .bundle.sha256 -print0 \ + | sort -z \ + | xargs -0 sha256sum \ + | sha256sum \ + | awk '{print $1}')" +printf '%s\n' "$BUNDLE_CHECKSUM" > /pkgs/.bundle.sha256 + echo "" echo "==============================================" echo " Package initialization complete!" diff --git a/javascript-packages.txt b/javascript-packages.txt index 2ce6347..c7933cf 100644 --- a/javascript-packages.txt +++ b/javascript-packages.txt @@ -3,7 +3,7 @@ zod@4.3.6 lodash@4.18.1 date-fns@4.1.0 -nanoid@5.1.9 +nanoid@5.1.16 uuid@14.0.0 pino@10.3.1 pino-pretty@13.1.3 @@ -12,14 +12,14 @@ chalk@5.6.2 debug@4.4.3 fast-glob@3.3.3 glob@13.0.6 -sharp@0.34.5 +sharp@0.35.0 pdf-lib@1.17.1 exceljs@4.4.0 mammoth@1.12.0 marked@18.0.2 markdown-it@14.1.1 papaparse@5.5.3 -js-yaml@4.1.1 +js-yaml@4.3.1 cheerio@1.2.0 ajv@8.20.0 gray-matter@4.0.3 @@ -30,5 +30,5 @@ yargs@18.0.0 handlebars@4.7.9 ejs@5.0.2 archiver@7.0.1 -adm-zip@0.5.17 +adm-zip@0.6.0 pptxgenjs@4.0.1 diff --git a/scripts/verify-file-generation-runtime.py b/scripts/verify-file-generation-runtime.py new file mode 100755 index 0000000..45e3961 --- /dev/null +++ b/scripts/verify-file-generation-runtime.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +"""Verify native file generation inside the sandbox runtime.""" + +from __future__ import annotations + +import csv +import json +import math +import platform +import struct +import subprocess +import tempfile +import wave +import xml.etree.ElementTree as ET +import zipfile +from importlib import metadata +from packaging.version import Version +from pathlib import Path + +from docx import Document +from openpyxl import Workbook, load_workbook +from PIL import Image +from pptx import Presentation +from pypdf import PdfReader +from reportlab.pdfgen import canvas + + +REQUIRED_DISTRIBUTIONS = ( + "openpyxl", + "xlsxwriter", + "pandas", + "numpy", + "python-docx", + "docxtpl", + "python-pptx", + "pillow", + "pypdf", + "pypdf2", + "pdfplumber", + "reportlab", + "weasyprint", + "msgpack", + "setuptools", +) + + +def run(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run(args, check=True, capture_output=True, text=True) + + +def ffprobe(path: Path) -> dict: + result = run( + "ffprobe", + "-v", + "error", + "-show_streams", + "-show_format", + "-of", + "json", + str(path), + ) + return json.loads(result.stdout) + + +def require(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def require_ooxml(path: Path, member: str) -> None: + require(zipfile.is_zipfile(path), f"{path.name} is not a ZIP-based OOXML file") + with zipfile.ZipFile(path) as archive: + names = set(archive.namelist()) + require("[Content_Types].xml" in names, f"{path.name} has no OOXML content types") + require(member in names, f"{path.name} is missing {member}") + + +def require_pdf(path: Path) -> None: + require(path.stat().st_size > 100, f"{path.name} is empty") + require(path.read_bytes()[:4] == b"%PDF", f"{path.name} has no PDF signature") + require(len(PdfReader(path).pages) >= 1, f"{path.name} cannot be parsed") + + +def require_media( + path: Path, + *, + format_name: str, + codecs: dict[str, str], + minimum_duration: float = 0.8, +) -> None: + require(path.stat().st_size > 100, f"{path.name} is empty") + info = ffprobe(path) + actual_format = str(info.get("format", {}).get("format_name", "")) + require(format_name in actual_format.split(","), f"{path.name} container is {actual_format}") + + duration = float(info.get("format", {}).get("duration") or 0) + require(duration >= minimum_duration, f"{path.name} duration is {duration}") + + streams = { + str(stream.get("codec_type")): str(stream.get("codec_name")) + for stream in info.get("streams", []) + } + for stream_type, codec in codecs.items(): + require( + streams.get(stream_type) == codec, + f"{path.name} {stream_type} codec is {streams.get(stream_type)}", + ) + + +def command_version(*args: str) -> str: + result = run(*args) + output = result.stdout.strip() or result.stderr.strip() + return output.splitlines()[0] + + +def runtime_versions() -> dict[str, object]: + packages = {name: metadata.version(name) for name in REQUIRED_DISTRIBUTIONS} + require( + Version(packages["weasyprint"]) >= Version("68"), + f"WeasyPrint {packages['weasyprint']} is below the patched runtime baseline", + ) + require( + Version(packages["msgpack"]) >= Version("1.2.1"), + f"msgpack {packages['msgpack']} is below the patched runtime baseline", + ) + require( + Version(packages["setuptools"]) >= Version("78.1.1"), + f"setuptools {packages['setuptools']} is below the patched runtime baseline", + ) + return { + "python": platform.python_version(), + "libreoffice": command_version("libreoffice", "--version"), + "ffmpeg": command_version("ffmpeg", "-version"), + "weasyprint": command_version("weasyprint", "--version"), + "packages": packages, + } + + +def package_bundle_checksum() -> str: + checksum_path = Path("/pkgs/.bundle.sha256") + require(checksum_path.is_file(), "/pkgs/.bundle.sha256 is missing") + checksum = checksum_path.read_text(encoding="ascii").strip() + require(len(checksum) == 64, "package bundle checksum has an invalid length") + require( + all(character in "0123456789abcdef" for character in checksum), + "package bundle checksum is invalid", + ) + return checksum + + +def main() -> None: + results: dict[str, str] = {} + with tempfile.TemporaryDirectory(prefix="wowloop-file-runtime-") as tmp: + root = Path(tmp) + + xlsx = root / "report.xlsx" + workbook = Workbook() + sheet = workbook.active + sheet.title = "Launch Summary" + sheet.append(["Metric", "Value"]) + sheet.append(["Revenue", 1250]) + sheet["B3"] = "=B2*1.1" + workbook.save(xlsx) + reopened = load_workbook(xlsx, data_only=False) + require(reopened["Launch Summary"]["B3"].value == "=B2*1.1", "xlsx formula missing") + require_ooxml(xlsx, "xl/workbook.xml") + results["xlsx"] = "pass" + + fake_xlsx = root / "renamed-text.xlsx" + fake_xlsx.write_text("not a workbook", encoding="utf-8") + try: + load_workbook(fake_xlsx) + except zipfile.BadZipFile: + pass + else: + raise AssertionError("renamed text passed XLSX validation") + results["renamed_text_rejected"] = "pass" + + csv_path = root / "report.csv" + with csv_path.open("w", encoding="utf-8", newline="") as handle: + csv.writer(handle).writerows([["name", "value"], ["conversion", "12.5%"]]) + with csv_path.open(encoding="utf-8", newline="") as handle: + require(list(csv.reader(handle))[1] == ["conversion", "12.5%"], "csv parse failed") + results["csv"] = "pass" + + docx = root / "report.docx" + document = Document() + document.add_heading("WowLoop Report", 0) + document.add_paragraph("Validated structured document output.") + document.save(docx) + require(Document(docx).paragraphs[1].text.startswith("Validated"), "docx reopen failed") + require_ooxml(docx, "word/document.xml") + results["docx"] = "pass" + + pptx = root / "report.pptx" + deck = Presentation() + slide = deck.slides.add_slide(deck.slide_layouts[1]) + slide.shapes.title.text = "WowLoop" + slide.placeholders[1].text = "Validated presentation output" + deck.save(pptx) + require(Presentation(pptx).slides[0].shapes.title.text == "WowLoop", "pptx reopen failed") + require_ooxml(pptx, "ppt/presentation.xml") + results["pptx"] = "pass" + + pdf = root / "report.pdf" + pdf_canvas = canvas.Canvas(str(pdf)) + pdf_canvas.drawString(72, 760, "WowLoop validated PDF output") + pdf_canvas.save() + require_pdf(pdf) + results["pdf"] = "pass" + + for image_format, suffix in (("PNG", "png"), ("JPEG", "jpg"), ("WEBP", "webp")): + image_path = root / f"report.{suffix}" + Image.new("RGB", (160, 90), (28, 99, 220)).save(image_path, format=image_format) + with Image.open(image_path) as image: + image.verify() + with Image.open(image_path) as image: + require(image.format == image_format, f"{image_format} format mismatch") + require(image.size == (160, 90), f"{image_format} size mismatch") + results[suffix] = "pass" + + svg = root / "report.svg" + svg.write_text( + '' + '', + encoding="utf-8", + ) + require(ET.parse(svg).getroot().tag.endswith("svg"), "svg parse failed") + results["svg"] = "pass" + + wav = root / "report.wav" + sample_rate = 16000 + with wave.open(str(wav), "wb") as audio: + audio.setnchannels(1) + audio.setsampwidth(2) + audio.setframerate(sample_rate) + frames = bytearray() + for index in range(sample_rate): + sample = int(8000 * math.sin(2 * math.pi * 440 * index / sample_rate)) + frames.extend(struct.pack("WowLoop

Validated HTML to PDF conversion.

", encoding="utf-8") + html_pdf = root / "html-report.pdf" + run("weasyprint", str(html), str(html_pdf)) + require_pdf(html_pdf) + results["html_to_pdf"] = "pass" + + print( + json.dumps( + { + "status": "pass", + "formats": results, + "package_bundle_checksum": package_bundle_checksum(), + "versions": runtime_versions(), + }, + sort_keys=True, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/tests/file_generation_runtime_static.sh b/tests/file_generation_runtime_static.sh new file mode 100755 index 0000000..3b40f63 --- /dev/null +++ b/tests/file_generation_runtime_static.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +bash -n "$ROOT/build-packages.sh" +bash -n "$ROOT/docker/package-init.sh" +python3 - "$ROOT/scripts/verify-file-generation-runtime.py" <<'PY' +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +compile(path.read_text(encoding="utf-8"), str(path), "exec") +PY + +for package in libreoffice ffmpeg poppler-utils libpango-1.0-0 libpangoft2-1.0-0 shared-mime-info; do + grep -F "$package" "$ROOT/api/Dockerfile" >/dev/null +done +if grep -F ' weasyprint \' "$ROOT/api/Dockerfile" >/dev/null; then + echo 'Debian WeasyPrint is vulnerable; install the patched Python runtime package instead.' >&2 + exit 1 +fi + +for package in openpyxl xlsxwriter pandas numpy python-docx docxtpl python-pptx pillow pypdf pypdf2 pdfplumber reportlab weasyprint msgpack setuptools; do + grep -F "$package" "$ROOT/build-packages.sh" >/dev/null + grep -F "$package" "$ROOT/docker/package-init.sh" >/dev/null +done + +grep -F '/pkgs/.bundle.sha256' "$ROOT/build-packages.sh" >/dev/null +grep -F '/pkgs/.bundle.sha256' "$ROOT/docker/package-init.sh" >/dev/null +grep -F '"nanoid": "^3.3.18"' "$ROOT/api/package.json" >/dev/null +grep -F 'NPM_VERSION="${NPM_VERSION:-12.0.2}"' "$ROOT/docker/package-init.sh" >/dev/null +grep -F 'NPM_TAR_VERSION="${NPM_TAR_VERSION:-7.5.21}"' "$ROOT/docker/package-init.sh" >/dev/null +grep -F 'NPM_BRACE_EXPANSION_VERSION="${NPM_BRACE_EXPANSION_VERSION:-5.0.9}"' "$ROOT/docker/package-init.sh" >/dev/null +grep -F 'NPM_IP_ADDRESS_VERSION="${NPM_IP_ADDRESS_VERSION:-10.3.1}"' "$ROOT/docker/package-init.sh" >/dev/null +grep -F 'NPM_VERSION="${NPM_VERSION:-12.0.2}"' "$ROOT/build-packages.sh" >/dev/null +grep -F 'NPM_TAR_VERSION="${NPM_TAR_VERSION:-7.5.21}"' "$ROOT/build-packages.sh" >/dev/null +grep -F 'NPM_BRACE_EXPANSION_VERSION="${NPM_BRACE_EXPANSION_VERSION:-5.0.9}"' "$ROOT/build-packages.sh" >/dev/null +grep -F 'NPM_IP_ADDRESS_VERSION="${NPM_IP_ADDRESS_VERSION:-10.3.1}"' "$ROOT/build-packages.sh" >/dev/null +grep -F 'NPM_INSTALL_ATTEMPTS="${NPM_INSTALL_ATTEMPTS:-3}"' "$ROOT/docker/package-init.sh" >/dev/null +sed -n '/^npm_runtime_ready()/,/^}/p' "$ROOT/docker/package-init.sh" \ + | grep -F 'PATH="${node_root}/bin:$PATH"' >/dev/null +grep -F 'remove_nonruntime_pip_sbom "${PKG_DEST}/bin/python3"' "$ROOT/docker/package-init.sh" >/dev/null +grep -F 'bom.cdx.json").unlink(missing_ok=True)' "$ROOT/build-packages.sh" >/dev/null +for package in tar brace-expansion ip-address; do + grep -F "install_npm_dependency_patch" "$ROOT/docker/package-init.sh" >/dev/null + grep -F "$package" "$ROOT/docker/package-init.sh" >/dev/null + grep -F "patch_npm_dependency $package" "$ROOT/build-packages.sh" >/dev/null +done +for package in nanoid@5.1.16 sharp@0.35.0 js-yaml@4.3.1 adm-zip@0.6.0; do + grep -Fx "$package" "$ROOT/javascript-packages.txt" >/dev/null +done +grep -F '/host-packages/** r,' "$ROOT/apparmor/sandbox-nsjail" >/dev/null +for mount_path in /etc/alternatives /etc/fonts /etc/libreoffice /var/cache/fontconfig; do + grep -F "src: \"$mount_path\"" "$ROOT/api/config/sandbox.cfg" >/dev/null +done + +echo 'OK: file-generation runtime dependencies and validation hooks are present.' From 9ed90d92a54a6283bc2fd0b4f8dc820081acb297 Mon Sep 17 00:00:00 2001 From: Thanh Nguyen Date: Thu, 27 Aug 2026 00:43:38 +0700 Subject: [PATCH 2/3] fix(runtime): support hardened Linux direct sandbox startup --- api/Dockerfile | 2 + api/src/entrypoint.sh | 7 +++- api/src/nsjail.test.ts | 3 +- api/src/nsjail.ts | 8 ++-- apparmor/sandbox-nsjail | 28 +++++++++++-- docker/start-direct-sandbox.sh | 27 +++++++----- helm/codeapi/templates/seccomp-profile.yaml | 2 +- seccomp/README.md | 2 +- seccomp/nsjail.json | 3 ++ tests/file_generation_runtime_static.sh | 46 +++++++++++++++++++++ 10 files changed, 106 insertions(+), 22 deletions(-) diff --git a/api/Dockerfile b/api/Dockerfile index 93ad5ac..ea2dfd5 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -216,6 +216,8 @@ RUN dnf install -y --setopt=install_weak_deps=False \ ca-certificates \ && dnf clean all +RUN mkdir -p /etc/alternatives /etc/fonts /etc/libreoffice /var/cache/fontconfig + COPY --from=launcher-builder /launcher/target/release/sandbox-launcher /usr/local/bin/launcher COPY launcher/entrypoint.sh /usr/local/bin/launcher-entrypoint.sh diff --git a/api/src/entrypoint.sh b/api/src/entrypoint.sh index b532a67..0095275 100755 --- a/api/src/entrypoint.sh +++ b/api/src/entrypoint.sh @@ -47,8 +47,11 @@ if grep -q virtiofs /proc/filesystems 2>/dev/null; then fi fi -# Restrict dmesg access (requires CAP_SYSLOG to read kernel ring buffer) -echo 1 > /proc/sys/kernel/dmesg_restrict 2>/dev/null || true +# Only change dmesg policy inside the dedicated guest kernel. Direct mode must +# not attempt to mutate the host kernel's sysctl namespace. +if [ "${KVM_ENABLED:-true}" = "true" ]; then + echo 1 > /proc/sys/kernel/dmesg_restrict 2>/dev/null || true +fi # Remove Kubernetes/containerd proc masks so NsJail can mount fresh procfs. # Container runtimes add submounts to /proc (kcore, keys, timer_list, etc.) diff --git a/api/src/nsjail.test.ts b/api/src/nsjail.test.ts index 549529a..e26100e 100644 --- a/api/src/nsjail.test.ts +++ b/api/src/nsjail.test.ts @@ -368,7 +368,7 @@ describe('NsJail seccomp policy', () => { test('KILLs the new mount API family (Linux 5.2+)', () => { const policy = seccompPolicy(); - for (const name of ['move_mount', 'open_tree', 'fsopen', 'fsmount', 'fspick']) { + for (const name of ['move_mount', 'open_tree', 'mount_setattr', 'fsopen', 'fsmount', 'fspick']) { expect(policy).toMatch(new RegExp(`\\b${name}\\b[,\\s]`)); } }); @@ -380,6 +380,7 @@ describe('NsJail seccomp policy', () => { expect(policy).toContain('#define fsopen 430'); expect(policy).toContain('#define fsmount 432'); expect(policy).toContain('#define fspick 433'); + expect(policy).toContain('#define mount_setattr 442'); }); test('KILLs AF_VSOCK in the socket(domain) filter', () => { diff --git a/api/src/nsjail.ts b/api/src/nsjail.ts index fc686c1..fd50333 100644 --- a/api/src/nsjail.ts +++ b/api/src/nsjail.ts @@ -46,6 +46,7 @@ const sharedSyscallDefines = [ '#define fsopen 430', '#define fsmount 432', '#define fspick 433', + '#define mount_setattr 442', /* pidfd_* are Linux 5.1+/5.3+ — newer than Kafel's bundled symbol * table on the pinned NsJail snapshot, so define numerically. Same * number on x86_64 and arm64. */ @@ -111,9 +112,10 @@ const SECCOMP_POLICY = [ ' add_key, request_key, keyctl,', ' mount, umount2, pivot_root,', /* New mount API (Linux 5.2+) — orthogonal to mount(2) and not covered by - * the line above. open_tree+move_mount can replicate a bind-mount; fsopen/ - * fsmount/fspick form the new filesystem-context flow. Block all five. */ - ' move_mount, open_tree, fsopen, fsmount, fspick,', + * the line above. open_tree+mount_setattr+move_mount can replicate a + * read-only bind-mount; fsopen/fsmount/fspick form the filesystem-context + * flow. The runner needs these before NsJail starts, but sandboxed code does not. */ + ' move_mount, open_tree, mount_setattr, fsopen, fsmount, fspick,', ' swapon, swapoff, reboot,', ' init_module, finit_module, delete_module,', /* setns joins an existing namespace via fd. Unshare is already blocked diff --git a/apparmor/sandbox-nsjail b/apparmor/sandbox-nsjail index 607cfd3..b9511e9 100644 --- a/apparmor/sandbox-nsjail +++ b/apparmor/sandbox-nsjail @@ -42,13 +42,33 @@ profile sandbox-nsjail flags=(attach_disconnected,mediate_deleted) { # ========================================================================== # File access rules # ========================================================================== - + + / r, + # Full access to sandbox working directories - /pkgs/** rwlk, + /pkgs/ rw, + /pkgs/** rwmlkix, /host-packages/ r, /host-packages/** r, + /sandbox_api/ rw, + /sandbox_api/** rix, + /sandbox-rootfs/ r, + /sandbox-rootfs/** r, + /tmp/ rw, /tmp/** rwlk, + /mnt/ rw, /mnt/** rwlk, + /run/mount/ rw, + /run/mount/** rwk, + /run/user/ rw, + /run/user/** rwk, + + # util-linux mount and libc identity lookups used during direct startup + /etc/fstab r, + /etc/passwd r, + /etc/group r, + /etc/nsswitch.conf r, + /etc/authselect/nsswitch.conf r, # Read-only registries required by native document and media tools /etc/alternatives/ r, @@ -62,7 +82,6 @@ profile sandbox-nsjail flags=(attach_disconnected,mediate_deleted) { # NsJail binary and config /usr/sbin/nsjail rix, - /sandbox_api/** r, /sandbox_api/config/** r, # Runtime executables (Python, Node, Bun, etc.) @@ -81,6 +100,9 @@ profile sandbox-nsjail flags=(attach_disconnected,mediate_deleted) { # Proc filesystem (read-only for most, some writes for cgroups) /proc/** r, + /proc/[0-9]*/uid_map rw, + /proc/[0-9]*/gid_map rw, + /proc/[0-9]*/setgroups rw, /proc/sys/kernel/random/uuid r, # Cgroups v2 (NsJail uses these for resource limits) diff --git a/docker/start-direct-sandbox.sh b/docker/start-direct-sandbox.sh index a171a6d..a1c36fc 100644 --- a/docker/start-direct-sandbox.sh +++ b/docker/start-direct-sandbox.sh @@ -48,25 +48,30 @@ export SANDBOX_ROOTFS="$ROOTFS" exec unshare --mount bash -c ' ROOTFS="${SANDBOX_ROOTFS:-/sandbox-rootfs}" - mount -o bind,ro "$ROOTFS/usr/sbin" /usr/sbin || { echo "FATAL: cannot bind /usr/sbin"; exit 1; } - mount -o bind,ro "$ROOTFS/usr/lib" /usr/lib || { echo "FATAL: cannot bind /usr/lib"; exit 1; } - - if [ -d "$ROOTFS/usr/lib64" ] && ! [ -L "$ROOTFS/usr/lib64" ]; then - mount -o bind,ro "$ROOTFS/usr/lib64" /usr/lib64 2>/dev/null || \ - echo "[sandbox] WARNING: could not bind /usr/lib64 - sandboxed binaries may fail to exec" - fi + # Bind the usr-merged tree atomically. Mounting /usr/sbin separately first + # resolves to /usr/bin on Fedora and hides the mount binary needed below. + mount -o bind,ro "$ROOTFS/usr" /usr || { echo "FATAL: cannot bind /usr"; exit 1; } + hash -r + + # Keep native-tool registries from the same Debian rootfs as /usr. Leaving + # the Fedora runner copies in place breaks alternatives-backed libraries. + for registry in /etc/alternatives /etc/fonts /etc/libreoffice /var/cache/fontconfig; do + if [ -d "$ROOTFS$registry" ]; then + mount -o bind,ro "$ROOTFS$registry" "$registry" || { + echo "FATAL: cannot bind $registry" + exit 1 + } + fi + done - mount -o bind,ro "$ROOTFS/usr/local" /usr/local || { echo "FATAL: cannot bind /usr/local"; exit 1; } mount -o bind,ro "$ROOTFS/sandbox_api" /sandbox_api || { echo "FATAL: cannot bind /sandbox_api"; exit 1; } - mount -o bind,ro "$ROOTFS/pkgs" /pkgs || { echo "FATAL: cannot bind /pkgs"; exit 1; } + mount -o bind,ro "$ROOTFS/pkgs" /pkgs || { echo "FATAL: cannot bind /pkgs"; exit 1; } if [ -d /host-packages ]; then mount --bind /host-packages /pkgs 2>/dev/null || \ echo "WARNING: could not bind /host-packages - sandbox will run without packages" fi - mount -o bind,ro "$ROOTFS/usr/bin" /usr/bin || { echo "FATAL: cannot bind /usr/bin"; exit 1; } - multiarch_libdir=$(find /usr/lib -maxdepth 1 -type d -name "*-linux-gnu" -print -quit) if [ -n "$multiarch_libdir" ]; then export LD_LIBRARY_PATH="$multiarch_libdir${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" diff --git a/helm/codeapi/templates/seccomp-profile.yaml b/helm/codeapi/templates/seccomp-profile.yaml index e592237..97860b6 100644 --- a/helm/codeapi/templates/seccomp-profile.yaml +++ b/helm/codeapi/templates/seccomp-profile.yaml @@ -135,7 +135,7 @@ data: }, { "comment": "NsJail mount operations - requires CAP_SYS_ADMIN", - "names": ["mount", "umount", "umount2", "pivot_root"], + "names": ["mount", "open_tree", "mount_setattr", "move_mount", "umount", "umount2", "pivot_root"], "action": "SCMP_ACT_ALLOW" }, { diff --git a/seccomp/README.md b/seccomp/README.md index e57fceb..f01204c 100644 --- a/seccomp/README.md +++ b/seccomp/README.md @@ -12,7 +12,7 @@ The `nsjail.json` profile uses a whitelist approach - only explicitly allowed sy 2. **NsJail namespace operations** - `clone`, `clone3`, `setns`, `unshare`, `sethostname`, `setdomainname` -3. **NsJail mount operations** - `mount`, `umount`, `umount2`, `pivot_root` +3. **NsJail mount operations** - legacy mount syscalls plus the modern bind-mount flow used by util-linux (`open_tree`, `mount_setattr`, `move_mount`) 4. **NsJail chroot** - `chroot` diff --git a/seccomp/nsjail.json b/seccomp/nsjail.json index 691d2c1..66b1a48 100644 --- a/seccomp/nsjail.json +++ b/seccomp/nsjail.json @@ -358,6 +358,9 @@ "comment": "NsJail mount operations - requires CAP_SYS_ADMIN", "names": [ "mount", + "open_tree", + "mount_setattr", + "move_mount", "umount", "umount2", "pivot_root" diff --git a/tests/file_generation_runtime_static.sh b/tests/file_generation_runtime_static.sh index 3b40f63..02f74fc 100755 --- a/tests/file_generation_runtime_static.sh +++ b/tests/file_generation_runtime_static.sh @@ -13,6 +13,24 @@ path = Path(sys.argv[1]) compile(path.read_text(encoding="utf-8"), str(path), "exec") PY +python3 - "$ROOT/seccomp/nsjail.json" "$ROOT/helm/codeapi/templates/seccomp-profile.yaml" <<'PY' +import json +from pathlib import Path +import sys + +profile = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +mount_rule = next( + rule for rule in profile["syscalls"] + if rule.get("comment") == "NsJail mount operations - requires CAP_SYS_ADMIN" +) +required = {"open_tree", "mount_setattr", "move_mount"} +assert required <= set(mount_rule["names"]) + +helm_profile = Path(sys.argv[2]).read_text(encoding="utf-8") +for syscall in required: + assert f'"{syscall}"' in helm_profile +PY + for package in libreoffice ffmpeg poppler-utils libpango-1.0-0 libpangoft2-1.0-0 shared-mime-info; do grep -F "$package" "$ROOT/api/Dockerfile" >/dev/null done @@ -51,6 +69,34 @@ for package in nanoid@5.1.16 sharp@0.35.0 js-yaml@4.3.1 adm-zip@0.6.0; do grep -Fx "$package" "$ROOT/javascript-packages.txt" >/dev/null done grep -F '/host-packages/** r,' "$ROOT/apparmor/sandbox-nsjail" >/dev/null +for rule in \ + '/pkgs/ rw,' \ + '/pkgs/** rwmlkix,' \ + '/sandbox_api/ rw,' \ + '/sandbox_api/** rix,' \ + '/sandbox-rootfs/** r,' \ + '/tmp/ rw,' \ + '/run/mount/** rwk,' \ + '/run/user/** rwk,' \ + '/etc/fstab r,' \ + '/etc/passwd r,' \ + '/proc/[0-9]*/uid_map rw,' \ + '/proc/[0-9]*/gid_map rw,' \ + '/proc/[0-9]*/setgroups rw,'; do + grep -F "$rule" "$ROOT/apparmor/sandbox-nsjail" >/dev/null +done +grep -F 'mount -o bind,ro "$ROOTFS/usr"' "$ROOT/docker/start-direct-sandbox.sh" >/dev/null +grep -F ' hash -r' "$ROOT/docker/start-direct-sandbox.sh" >/dev/null +grep -F 'RUN mkdir -p /etc/alternatives /etc/fonts /etc/libreoffice /var/cache/fontconfig' "$ROOT/api/Dockerfile" >/dev/null +grep -F 'for registry in /etc/alternatives /etc/fonts /etc/libreoffice /var/cache/fontconfig; do' \ + "$ROOT/docker/start-direct-sandbox.sh" >/dev/null +grep -F 'mount -o bind,ro "$ROOTFS$registry" "$registry"' \ + "$ROOT/docker/start-direct-sandbox.sh" >/dev/null +grep -F 'if [ "${KVM_ENABLED:-true}" = "true" ]; then' "$ROOT/api/src/entrypoint.sh" >/dev/null +if grep -F 'mount -o bind,ro "$ROOTFS/usr/sbin"' "$ROOT/docker/start-direct-sandbox.sh" >/dev/null; then + echo 'Direct startup must bind the usr-merged tree atomically.' >&2 + exit 1 +fi for mount_path in /etc/alternatives /etc/fonts /etc/libreoffice /var/cache/fontconfig; do grep -F "src: \"$mount_path\"" "$ROOT/api/config/sandbox.cfg" >/dev/null done From e16182b88eec45b383f2d3b42475d2429acbc3c7 Mon Sep 17 00:00:00 2001 From: Thanh Nguyen Date: Thu, 27 Aug 2026 08:01:25 +0700 Subject: [PATCH 3/3] fix(runtime): allow resolver access in direct sandbox --- apparmor/sandbox-nsjail | 5 +++++ tests/file_generation_runtime_static.sh | 2 ++ 2 files changed, 7 insertions(+) diff --git a/apparmor/sandbox-nsjail b/apparmor/sandbox-nsjail index b9511e9..6475250 100644 --- a/apparmor/sandbox-nsjail +++ b/apparmor/sandbox-nsjail @@ -69,6 +69,11 @@ profile sandbox-nsjail flags=(attach_disconnected,mediate_deleted) { /etc/group r, /etc/nsswitch.conf r, /etc/authselect/nsswitch.conf r, + /etc/host.conf r, + /etc/hosts r, + /etc/resolv.conf r, + /etc/gai.conf r, + /etc/services r, # Read-only registries required by native document and media tools /etc/alternatives/ r, diff --git a/tests/file_generation_runtime_static.sh b/tests/file_generation_runtime_static.sh index 02f74fc..af9853d 100755 --- a/tests/file_generation_runtime_static.sh +++ b/tests/file_generation_runtime_static.sh @@ -80,6 +80,8 @@ for rule in \ '/run/user/** rwk,' \ '/etc/fstab r,' \ '/etc/passwd r,' \ + '/etc/hosts r,' \ + '/etc/resolv.conf r,' \ '/proc/[0-9]*/uid_map rw,' \ '/proc/[0-9]*/gid_map rw,' \ '/proc/[0-9]*/setgroups rw,'; do