From 126277bf0562f14e4702bf4479b9dff03283a546 Mon Sep 17 00:00:00 2001 From: Valeriy Khakhutskyy <1292899+valeriy42@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:22:50 +0200 Subject: [PATCH] [ML] Typed filesystem/network launch policy for Sandbox2 pytorch_inference Replaces raw argument-directory inference with a typed launch spec that validates every input/output/restore/logPipe path against a pinned child-root contract before any policy is built: each must canonicalize to exactly $TMPDIR/ml-child-ipc//, for one consistent . Rejects relative, root-level, dot-dot, out-of-root, wrong-depth, duplicate, and mutable-symlink/alias paths - never widens a mount to recover a rejected argument. Minimizes the filesystem policy: enumerates and justifies all seven historically bulk-mounted fixed directories (/lib /lib64 /usr/lib /usr/lib64 /etc /proc /sys), each mounted only if its source actually exists on this host; replaces whole /etc with five individually justified files; never binds host /proc or /sys (relies on Sandbox2's own namespaced procfs/sysfs); uses a private bounded tmpfs at /tmp instead of the host's; consumes the syscall allowlist already shared with the legacy BPF filter instead of hand-duplicating it. Adds a purpose-built allowlisted mechanism probe proving allowed IPC access, denied host reads, denied external egress (narrowed to the actual no-route errno class), loopback reachability, and mount enumeration, plus a portable validator unit-test suite that runs on every POSIX ml-cpp CI platform without needing Sandbox2 itself, and a Linux-only mechanism integration test. Also fixes a Windows build break this change would otherwise have introduced: the new production file used POSIX-only realpath()/PATH_MAX unconditionally, but ml-cpp builds this library on every platform including Windows. canonicalize() now has a _WIN32 branch using _fullpath()/_MAX_PATH (inert until any Windows caller exists); the POSIX-only unit test is excluded from the Windows build instead. Verified this session: the validator's core logic compiles clean with -Wall -Wextra -Werror and passes a standalone driver covering every rejection/acceptance path (valid multi-pipe case, empty value, relative, root-level, dot-dot, too-shallow, too-deep, duplicate, symlink-alias, child-id-mismatch, scalar-options-ignored) against real mkdtemp/mkdir/symlink fixtures. The SANDBOX2_AVAILABLE/Linux PolicyBuilder path compiles clean with -Werror against stub sandbox2/ seccomp headers (no vendored Sandbox2 headers available on this host). Not yet verified: an actual Sandbox2 run of the mechanism probe and the real Linux CMake/build integration - needs a Linux CI or devbox pass. The /etc/ssl trust-bundle path is deliberately left out of the allowlisted /etc files pending confirmation of the actual path on the CI build image (Debian-style vs RHEL-style). --- .../sandbox/CPytorchInferenceSandboxPolicy.h | 148 +++++++ lib/sandbox/CMakeLists.txt | 11 +- lib/sandbox/CPytorchInferenceSandboxPolicy.cc | 378 ++++++++++++++++++ lib/sandbox/unittest/CMakeLists.txt | 30 ++ ...ferenceSandboxPolicyMechanismTest_Linux.cc | 182 +++++++++ .../CPytorchInferenceSandboxPolicyTest.cc | 266 ++++++++++++ .../unittest/payloads/ml_sandbox_probe.cc | 198 +++++++++ 7 files changed, 1209 insertions(+), 4 deletions(-) create mode 100644 include/sandbox/CPytorchInferenceSandboxPolicy.h create mode 100644 lib/sandbox/CPytorchInferenceSandboxPolicy.cc create mode 100644 lib/sandbox/unittest/CPytorchInferenceSandboxPolicyMechanismTest_Linux.cc create mode 100644 lib/sandbox/unittest/CPytorchInferenceSandboxPolicyTest.cc create mode 100644 lib/sandbox/unittest/payloads/ml_sandbox_probe.cc diff --git a/include/sandbox/CPytorchInferenceSandboxPolicy.h b/include/sandbox/CPytorchInferenceSandboxPolicy.h new file mode 100644 index 000000000..89495d48b --- /dev/null +++ b/include/sandbox/CPytorchInferenceSandboxPolicy.h @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the following additional limitation. Functionality enabled by the + * files subject to the Elastic License 2.0 may only be used in production when + * invoked by an Elasticsearch process with a license key installed that permits + * use of machine learning features. You may not use this file except in + * compliance with the Elastic License 2.0 and the foregoing additional + * limitation. + */ +#ifndef INCLUDED_ml_sandbox_CPytorchInferenceSandboxPolicy_h +#define INCLUDED_ml_sandbox_CPytorchInferenceSandboxPolicy_h + +#include +#include + +#ifdef SANDBOX2_AVAILABLE +#include +#endif + +namespace ml { +namespace sandbox { + +//! Reasons a path-bearing launch argument fails typed validation against the +//! pinned child-root contract (see validateChildIpcLaunchSpec below). Every +//! value here must fail *before* a policy is constructed; none of them widen +//! a mount to recover. +enum class EChildIpcPathRejection { + E_NotAbsolute, //!< value does not start with '/'. + E_RootLevelPath, //!< value has no mountable parent directory below '/'. + E_ContainsDotDot, //!< value has a ".." path component. + E_CanonicalizationFailed, //!< the trusted base or the value's parent directory could not be + //!< resolved (realpath() on POSIX, _fullpath() on Windows). + E_OutsideTrustedBase, //!< canonical parent is not beneath the trusted $TMPDIR. + E_WrongDepth, //!< canonical parent is not exactly $TMPDIR/ml-child-ipc/. + E_ChildIdMismatch, //!< two path options resolved to a different . + E_MutableSymlinkOrAlias, //!< the literal and canonical parent directories diverge. + E_Duplicate //!< the same literal argument was supplied more than once. +}; + +//! One rejected path-bearing argument and why. +struct SRejectedChildIpcPath { + std::string s_Arg; + EChildIpcPathRejection s_Reason; +}; + +//! A typed, validated launch specification for a single sandboxed +//! pytorch_inference child, derived from its path-bearing launch options +//! (input, output, restore, logPipe). Replaces raw argument-directory +//! inference: every accepted path is provably beneath the one pinned +//! per-child IPC root, never inferred from arbitrary argv content. +struct SChildIpcLaunchSpec { + //! path component shared by every accepted path option. + //! Empty whenever the overall result is not s_Ok - either no + //! recognized path option was present, or at least one was rejected + //! (SChildIpcValidationResult clears the whole spec on any rejection). + std::string s_ChildId; + //! Canonical $TMPDIR/ml-child-ipc/ - the directory the native + //! controller creates (mode 0700) before policy construction, and the + //! only host directory CSandboxedProcessSpawner maps to + //! /run/elastic/ml-ipc. Empty iff s_ChildId is empty. + std::string s_ChildIpcRoot; + //! Canonical paths of every accepted path-bearing argument, always + //! s_ChildIpcRoot plus exactly one leaf component. + std::vector s_PipePaths; +}; + +//! Result of validating a pytorch_inference launch command line against the +//! pinned child-root contract. +struct SChildIpcValidationResult { + //! True only when at least one path option was present and every + //! path option that was present was accepted. False means the caller + //! must fail the spawn - never fall back to a partially-built policy. + bool s_Ok = false; + SChildIpcLaunchSpec s_Spec; + std::vector s_Rejected; +}; + +//! Validate every input/output/restore/logPipe argument in args against the +//! pinned child-root contract: each must canonicalize to a parent directory +//! of exactly trustedTmpDir/ml-child-ipc/, for one consistent +//! , with no ".."; no relative, root, or out-of-root path; no +//! divergent literal/canonical parent; and no duplicate literal argument. +//! Scalar (non path-bearing) options are never inspected as candidate paths. +//! trustedTmpDir must already be the canonical form of the operator's +//! Environment.tmpDir(); this function does not itself decide what counts +//! as trusted. +SChildIpcValidationResult validateChildIpcLaunchSpec(const std::string& trustedTmpDir, + const std::vector& args); + +#ifdef SANDBOX2_AVAILABLE + +//! What buildPytorchInferenceFilesystemPolicy does with one of the seven +//! historically bulk-mounted fixed directories +//! (/lib /lib64 /usr/lib /usr/lib64 /etc /proc /sys). Mounting whole /etc or +//! binding the host's /proc or /sys directly is non-conformant. +enum class EFixedMountAction { + E_MountReadOnlyDirectory, //!< the whole directory is demonstrated necessary read-only. + E_MountNamespacedProcfs, //!< Sandbox2 supplies this inside the sandbox's own PID/mount namespace; never bind the host directory. + E_Skip //!< not mapped at all; narrower entries (files) are added separately. +}; + +//! One fixed-mount decision plus the reason it is scoped that way. +struct SFixedMountDecision { + std::string s_Path; + EFixedMountAction s_Action; + std::string s_Reason; +}; + +//! The minimization decision applied to each of the seven historically +//! bulk-mounted fixed directories, with its justification. /etc is Skip +//! (see allowlistedEtcFiles() for the narrower replacement); /proc and /sys +//! are the Sandbox2-namespaced procfs/sysfs, never a host bind (the +//! ml_sandbox_probe mechanism test asserts this held for a real launch, via +//! its pid_namespace check). /lib, /lib64, /usr/lib, /usr/lib64 remain whole +//! read-only directories: the dynamic loader resolves libtorch/glibc shared +//! objects from them at runtime from an unbounded, platform-dependent set, +//! so per-file allowlisting would duplicate the loader's own search logic. +const std::vector& fixedMountDecisions(); + +//! Individual /etc files pytorch_inference/libtorch are demonstrated to +//! need, replacing a whole-/etc bind. Extend only with a named consumer. +const std::vector& allowlistedEtcFiles(); + +//! Builds the filesystem and network-shape portion of the pytorch_inference +//! Sandbox2 policy: minimized fixed mounts (fixedMountDecisions, +//! allowlistedEtcFiles - a read-only directory decision is mounted only if +//! its source actually exists on this host, since Sandbox2 fails the whole +//! spawn on a missing source), a private bounded tmpfs at /tmp, the one per-child +//! IPC root mapped to /run/elastic/ml-ipc, and the syscall allowlist shared +//! with the legacy BPF filter +//! (seccomp::pytorch_inference::legacyBpfAllowedSyscalls, kept in sync per +//! that header's own comment). Does not call TryBuild() - the caller owns +//! final policy construction so tests can inspect the builder before +//! commit. spec must already be s_Ok from validateChildIpcLaunchSpec; this +//! function does not re-validate it. +sandbox2::PolicyBuilder +buildPytorchInferenceFilesystemPolicy(const std::string& binDir, + const std::string& libDir, + const SChildIpcLaunchSpec& spec, + std::size_t tmpfsSizeBytes); + +#endif // SANDBOX2_AVAILABLE + +} // namespace sandbox +} // namespace ml + +#endif // INCLUDED_ml_sandbox_CPytorchInferenceSandboxPolicy_h diff --git a/lib/sandbox/CMakeLists.txt b/lib/sandbox/CMakeLists.txt index 508a46029..89170eb66 100644 --- a/lib/sandbox/CMakeLists.txt +++ b/lib/sandbox/CMakeLists.txt @@ -9,19 +9,22 @@ # limitation. # -# MlSandbox is a dormant dependency foundation: it links Sandbox2/Abseil and -# builds/tests a runnable Sandbox2 forkserver on Linux, but no controller or -# pytorch_inference routing depends on it yet. The typed launch policy, -# process spawner, and controller wiring land in follow-up PRs. +# MlSandbox links Sandbox2/Abseil and builds a runnable Sandbox2 forkserver +# on Linux, and now a typed filesystem/network launch policy for a +# pytorch_inference child. No controller or pytorch_inference routing +# depends on it yet - the process spawner and controller wiring land in +# follow-up PRs. project("ML Sandbox") set(ML_LINK_LIBRARIES MlCore + MlSeccomp ) set(SRCS CMlSandboxAvailability.cc + CPytorchInferenceSandboxPolicy.cc ) ml_add_library(MlSandbox STATIC ${SRCS}) diff --git a/lib/sandbox/CPytorchInferenceSandboxPolicy.cc b/lib/sandbox/CPytorchInferenceSandboxPolicy.cc new file mode 100644 index 000000000..3fd7efca1 --- /dev/null +++ b/lib/sandbox/CPytorchInferenceSandboxPolicy.cc @@ -0,0 +1,378 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the following additional limitation. Functionality enabled by the + * files subject to the Elastic License 2.0 may only be used in production when + * invoked by an Elasticsearch process with a license key installed that permits + * use of machine learning features. You may not use this file except in + * compliance with the Elastic License 2.0 and the foregoing additional + * limitation. + */ +#include + +#ifdef _WIN32 +#include // _fullpath, _MAX_PATH +#else +#include // PATH_MAX +#include +#endif + +#include +#include + +#ifdef SANDBOX2_AVAILABLE +#include +#endif + +#ifdef __linux__ +#include +#endif + +namespace ml { +namespace sandbox { + +namespace { + +//! The only recognized path-bearing launch options. Adding or renaming one +//! requires a change here, a policy test, and an end-to-end Elasticsearch +//! invocation test. +bool isPathOptionName(const std::string& name) { + return name == "input" || name == "output" || name == "restore" || name == "logPipe"; +} + +//! Split a path into components, without resolving "." or "..". +std::vector splitPathComponents(const std::string& path) { + std::vector components; + std::string current; + for (char c : path) { + if (c == '/') { + if (current.empty() == false) { + components.push_back(current); + current.clear(); + } + } else { + current.push_back(c); + } + } + if (current.empty() == false) { + components.push_back(current); + } + return components; +} + +bool containsDotDot(const std::vector& components) { + return std::find(components.begin(), components.end(), "..") != components.end(); +} + +//! realpath() requires the target to exist. The leaf FIFO/file may not +//! exist yet at validation time, but the native controller creates the +//! per-child ml-child-ipc/ directory before policy construction, +//! so canonicalizing the *parent* directory of the leaf is always +//! meaningful. +bool canonicalize(const std::string& dir, std::string& canonicalOut) { +#ifdef _WIN32 + // Sandbox2 (and therefore every caller of this validator) is Linux-only + // - nothing wires this function up on Windows today - but ml-cpp builds + // this file unconditionally on every platform (see + // lib/sandbox/CMakeLists.txt), so it still has to compile and behave + // sanely there. _fullpath() differs from realpath() in not requiring + // the target to exist; that is inert until a Windows caller exists. + char resolved[_MAX_PATH]; + if (::_fullpath(resolved, dir.c_str(), _MAX_PATH) == nullptr) { + return false; + } +#else + char resolved[PATH_MAX]; + if (::realpath(dir.c_str(), resolved) == nullptr) { + return false; + } +#endif + canonicalOut.assign(resolved); + return true; +} + +} // namespace + +SChildIpcValidationResult validateChildIpcLaunchSpec(const std::string& trustedTmpDir, + const std::vector& args) { + SChildIpcValidationResult result; + + std::string trustedTmpDirCanonical; + const bool trustedBaseResolved = canonicalize(trustedTmpDir, trustedTmpDirCanonical); + + std::vector seenLiteralArgs; + + for (const std::string& arg : args) { + const std::size_t eqPos = arg.find('='); + if (eqPos == std::string::npos) { + // NOTE (reviewed, not fixed): CCmdLineParser.cc's + // boost::program_options parser also accepts spellings other + // than the exact concatenated "--=" form this loop + // requires - a space-separated "--input /path", or (via boost's + // default allow_guessing style) an unambiguous abbreviation + // like "--inp=/path". None of those are a mount-widening bypass: + // an unrecognized option is never added to s_PipePaths, so its + // directory is simply never mounted and the spawn either fails + // closed (pipe unreachable) or gets rejected elsewhere. The sole + // production caller, ProcessPipes.addArgs() in + // elasticsearch/x-pack/plugin/ml, always emits the exact + // concatenated "--input=" + value form, so this is a defensive + // fail-closed gap rather than an active exploit path. Left + // unfixed rather than special-cased. + continue; + } + + std::string optionName{arg.substr(0, eqPos)}; + while (optionName.empty() == false && optionName[0] == '-') { + optionName.erase(0, 1); + } + + if (isPathOptionName(optionName) == false) { + continue; + } + + // eqPos + 1 == arg.size() means an empty value ("--input="). That + // must still be classified as a recognized-but-malformed path + // option and rejected below (E_NotAbsolute), not silently skipped + // as if the option were absent - skipping it here would let a spec + // with a missing input path validate as s_Ok if the other three + // options happened to be valid. + const std::string value{eqPos + 1 < arg.size() ? arg.substr(eqPos + 1) + : std::string{}}; + + if (std::find(seenLiteralArgs.begin(), seenLiteralArgs.end(), arg) != + seenLiteralArgs.end()) { + result.s_Rejected.push_back({arg, EChildIpcPathRejection::E_Duplicate}); + continue; + } + seenLiteralArgs.push_back(arg); + + if (value.empty() || value[0] != '/') { + result.s_Rejected.push_back({arg, EChildIpcPathRejection::E_NotAbsolute}); + continue; + } + + const std::vector components{splitPathComponents(value)}; + if (containsDotDot(components)) { + result.s_Rejected.push_back({arg, EChildIpcPathRejection::E_ContainsDotDot}); + continue; + } + if (components.size() < 2) { + // Fewer than two components below '/' means either the root + // itself or a direct child of root - never a valid three-deep + // $TMPDIR/ml-child-ipc// path. + result.s_Rejected.push_back({arg, EChildIpcPathRejection::E_RootLevelPath}); + continue; + } + + const std::string leaf{components.back()}; + const std::size_t lastSlash = value.rfind('/'); + const std::string literalParent{value.substr(0, lastSlash)}; + + if (trustedBaseResolved == false) { + result.s_Rejected.push_back({arg, EChildIpcPathRejection::E_CanonicalizationFailed}); + continue; + } + + std::string canonicalParent; + if (canonicalize(literalParent, canonicalParent) == false) { + result.s_Rejected.push_back({arg, EChildIpcPathRejection::E_CanonicalizationFailed}); + continue; + } + + if (literalParent != canonicalParent) { + // The literal path traverses a symlink (or other alias) before + // reaching its parent directory. Accepting both forms - as the + // pre-PR-C raw inference did - would let a mutable link widen + // the mount after validation ran. Reject instead of mounting + // either form. + result.s_Rejected.push_back({arg, EChildIpcPathRejection::E_MutableSymlinkOrAlias}); + continue; + } + + const std::vector canonicalComponents{splitPathComponents(canonicalParent)}; + const std::vector baseComponents{splitPathComponents(trustedTmpDirCanonical)}; + + const bool underBase = canonicalComponents.size() == baseComponents.size() + 2 && + std::equal(baseComponents.begin(), baseComponents.end(), + canonicalComponents.begin()); + if (underBase == false) { + const bool sharesBasePrefix = + canonicalComponents.size() >= baseComponents.size() && + std::equal(baseComponents.begin(), baseComponents.end(), + canonicalComponents.begin()); + result.s_Rejected.push_back( + {arg, sharesBasePrefix ? EChildIpcPathRejection::E_WrongDepth + : EChildIpcPathRejection::E_OutsideTrustedBase}); + continue; + } + + const std::string intermediateDir{canonicalComponents[baseComponents.size()]}; + if (intermediateDir != "ml-child-ipc") { + result.s_Rejected.push_back({arg, EChildIpcPathRejection::E_WrongDepth}); + continue; + } + + const std::string childId{canonicalComponents.back()}; + if (result.s_Spec.s_ChildId.empty() == false && result.s_Spec.s_ChildId != childId) { + result.s_Rejected.push_back({arg, EChildIpcPathRejection::E_ChildIdMismatch}); + continue; + } + + result.s_Spec.s_ChildId = childId; + result.s_Spec.s_ChildIpcRoot = canonicalParent; + result.s_Spec.s_PipePaths.push_back(canonicalParent + "/" + leaf); + } + + result.s_Ok = result.s_Rejected.empty() && result.s_Spec.s_ChildId.empty() == false; + if (result.s_Ok == false) { + // A rejected argument or an entirely absent path option both fail + // the spawn; never return a partially-populated spec the caller + // might build a policy from by mistake. + result.s_Spec = SChildIpcLaunchSpec{}; + } + return result; +} + +#ifdef SANDBOX2_AVAILABLE + +const std::vector& fixedMountDecisions() { + static const std::vector DECISIONS{ + {"/lib", EFixedMountAction::E_MountReadOnlyDirectory, + "Dynamic loader resolves libc/libgcc/libstdc++ from here at " + "runtime; the set is unbounded and platform-dependent, so " + "per-file allowlisting would duplicate the loader's own search " + "logic."}, + {"/lib64", EFixedMountAction::E_MountReadOnlyDirectory, + "Same reason as /lib, on the lib64 multilib path used by the " + "64-bit dynamic loader on our supported Linux distributions."}, + {"/usr/lib", EFixedMountAction::E_MountReadOnlyDirectory, + "Same reason as /lib: libtorch and its transitive shared-library " + "dependencies resolve from here."}, + {"/usr/lib64", EFixedMountAction::E_MountReadOnlyDirectory, + "Same reason as /lib64, for 64-bit multilib packages."}, + {"/etc", EFixedMountAction::E_Skip, + "Whole /etc is never mounted; allowlistedEtcFiles() lists the " + "individually justified files pytorch_inference/libtorch actually " + "need instead."}, + {"/proc", EFixedMountAction::E_MountNamespacedProcfs, + "Sandbox2 mounts a fresh procfs inside the sandbox's own PID " + "namespace; binding the host's /proc would leak every other " + "process's memory maps and command lines into the sandbox."}, + {"/sys", EFixedMountAction::E_MountNamespacedProcfs, + "Same reason as /proc: nothing in this policy binds host /sys."}, + }; + return DECISIONS; +} + +const std::vector& allowlistedEtcFiles() { + // NOTE: /etc/ssl/certs/ca-certificates.crt is the Debian/Ubuntu trust + // bundle path; the ml-cpp CI build image is CentOS7/RHEL-based, whose + // equivalent is /etc/pki/tls/certs/ca-bundle.crt. This list has not yet + // been verified against the actual supported-distro trust bundle path - + // an open item, not resolved here. + static const std::vector FILES{ + "/etc/nsswitch.conf", "/etc/resolv.conf", "/etc/hosts", + "/etc/localtime", "/etc/ld.so.cache", + }; + return FILES; +} + +sandbox2::PolicyBuilder +buildPytorchInferenceFilesystemPolicy(const std::string& binDir, + const std::string& libDir, + const SChildIpcLaunchSpec& spec, + std::size_t tmpfsSizeBytes) { + sandbox2::PolicyBuilder policyBuilder; + + policyBuilder.AllowDynamicStartup() + .AllowExit() + .AllowHandleSignals() + .AllowGetPIDs() + .AllowGetRandom() + .AllowTcMalloc() + .AllowMmap(); + +#ifdef __linux__ + // glibc/libtorch use futex for mutexes and condition variables; timed + // waits and broadcast/requeue paths need more than plain WAIT/WAKE (see + // the carry-forward note on d9a856d5f in + // include/seccomp/CPytorchInferenceSyscallAllowlist.h). + policyBuilder.AllowFutexOp(FUTEX_WAIT) + .AllowFutexOp(FUTEX_WAKE) + .AllowFutexOp(FUTEX_WAIT_BITSET) + .AllowFutexOp(FUTEX_WAKE_BITSET) + .AllowFutexOp(FUTEX_REQUEUE) + .AllowFutexOp(FUTEX_CMP_REQUEUE) + .AllowFutexOp(FUTEX_WAKE_OP); +#endif + + // Consume the one machine-readable syscall declaration shared with the + // legacy in-process BPF filter instead of hand-maintaining a second list, + // so a future change to the allowlist keeps both mechanisms in sync + // automatically. + for (int syscallNr : seccomp::pytorch_inference::legacyBpfAllowedSyscalls()) { + policyBuilder.AllowSyscall(syscallNr); + } + + policyBuilder.AddDirectory(binDir, /*is_ro=*/true); + policyBuilder.AddDirectory(libDir, /*is_ro=*/true); + + for (const SFixedMountDecision& decision : fixedMountDecisions()) { + switch (decision.s_Action) { + case EFixedMountAction::E_MountReadOnlyDirectory: { + // Sandbox2's Mounts API has no "mount if present" option - it + // fails the whole spawn (not just this entry) if the source + // path doesn't exist. /lib64 and /usr/lib64 are RHEL/Rocky + // multilib paths that some supported distros' layouts don't + // have under every name; skip a decision whose source is + // simply absent on this host rather than crash the spawn over + // a directory nothing needed. + struct stat dirStat {}; + if (::stat(decision.s_Path.c_str(), &dirStat) == 0 && + S_ISDIR(dirStat.st_mode)) { + policyBuilder.AddDirectory(decision.s_Path, /*is_ro=*/true); + } + break; + } + case EFixedMountAction::E_MountNamespacedProcfs: + case EFixedMountAction::E_Skip: + // Sandbox2 supplies its own namespaced procfs/sysfs + // automatically; nothing to add here for either case, and + // adding decision.s_Path would bind the host directory instead. + break; + } + } + + for (const std::string& etcFile : allowlistedEtcFiles()) { + // Same reasoning as the fixed-directory guard above: a minimal or + // distroless-style host can be missing any one of these (e.g. + // /etc/resolv.conf under --network none), and Sandbox2's Mounts + // API fails the whole spawn, not just this entry, on an absent + // source. + struct stat fileStat {}; + if (::stat(etcFile.c_str(), &fileStat) == 0 && S_ISREG(fileStat.st_mode)) { + policyBuilder.AddFile(etcFile, /*is_ro=*/true); + } + } + + for (const std::string& devFile : {"/dev/null", "/dev/urandom", "/dev/random"}) { + policyBuilder.AddFile(devFile, /*is_ro=*/devFile != std::string{"/dev/null"}); + } + + // Private, bounded tmpfs - never the host's shared /tmp. + policyBuilder.AddTmpfs("/tmp", tmpfsSizeBytes); + + // The one per-child IPC root, mapped read-write to a fixed in-sandbox + // path. spec must already be s_Ok (validateChildIpcLaunchSpec), so + // s_ChildIpcRoot is exactly $TMPDIR/ml-child-ipc/ - never + // ml-child-ipc itself, never a sibling child's directory. + policyBuilder.AddDirectoryAt(spec.s_ChildIpcRoot, "/run/elastic/ml-ipc", /*is_ro=*/false); + + return policyBuilder; +} + +#endif // SANDBOX2_AVAILABLE + +} // namespace sandbox +} // namespace ml diff --git a/lib/sandbox/unittest/CMakeLists.txt b/lib/sandbox/unittest/CMakeLists.txt index ba783acc4..c0ad8e0a0 100644 --- a/lib/sandbox/unittest/CMakeLists.txt +++ b/lib/sandbox/unittest/CMakeLists.txt @@ -20,9 +20,20 @@ set(ML_LINK_LIBRARIES ${Boost_LIBRARIES_WITH_UNIT_TEST} MlCore MlSandbox + MlSeccomp MlTest ) +if(NOT WIN32) + # validateChildIpcLaunchSpec's production implementation is portable + # POSIX (Linux and macOS both verified), not Sandbox2/Linux-specific - + # unlike the smoke/mechanism tests below, it deliberately runs + # everywhere it can, which excludes only Windows (no realpath/mkdtemp/ + # symlink equivalents wired up; see canonicalize()'s _WIN32 branch in + # the .cc for why production code still has to compile there). + list(APPEND SRCS CPytorchInferenceSandboxPolicyTest.cc) +endif() + if(TARGET sandbox2::sandbox2 AND CMAKE_SYSTEM_NAME STREQUAL "Linux") # The forkserver runtime smoke test links the Sandbox2 API directly (not # just MlSandbox, which exposes no sandbox2 symbols yet) to prove the @@ -31,6 +42,7 @@ if(TARGET sandbox2::sandbox2 AND CMAKE_SYSTEM_NAME STREQUAL "Linux") # elsewhere rather than compiled out with #ifdef, since sandbox2 headers # are unavailable on non-Linux configure runs. list(APPEND SRCS CSandboxForkserverSmokeTest.cc) + list(APPEND SRCS CPytorchInferenceSandboxPolicyMechanismTest_Linux.cc) list(APPEND ML_LINK_LIBRARIES sandbox2::sandbox2) # Deliberately-dependency-free sandboxee payload for the smoke test above. @@ -50,6 +62,17 @@ if(TARGET sandbox2::sandbox2 AND CMAKE_SYSTEM_NAME STREQUAL "Linux") POSITION_INDEPENDENT_CODE TRUE RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/payloads ) + + # Purpose-built allowlisted mechanism-probe payload for the filesystem/ + # network policy test below. Same dependency-free, dynamically-linked + # pattern as sandbox2_smoke_payload above, for the same CI-image reason. + add_executable(ml_sandbox_probe EXCLUDE_FROM_ALL + payloads/ml_sandbox_probe.cc + ) + set_target_properties(ml_sandbox_probe PROPERTIES + POSITION_INDEPENDENT_CODE TRUE + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/payloads + ) endif() ml_add_test_executable(sandbox ${SRCS}) @@ -60,3 +83,10 @@ if(TARGET sandbox2_smoke_payload) "ML_SANDBOX2_SMOKE_PAYLOAD=\"$\"" ) endif() + +if(TARGET ml_sandbox_probe) + add_dependencies(ml_test_sandbox ml_sandbox_probe) + target_compile_definitions(ml_test_sandbox PRIVATE + ML_SANDBOX2_PROBE_PAYLOAD="$" + ) +endif() diff --git a/lib/sandbox/unittest/CPytorchInferenceSandboxPolicyMechanismTest_Linux.cc b/lib/sandbox/unittest/CPytorchInferenceSandboxPolicyMechanismTest_Linux.cc new file mode 100644 index 000000000..c22e010e2 --- /dev/null +++ b/lib/sandbox/unittest/CPytorchInferenceSandboxPolicyMechanismTest_Linux.cc @@ -0,0 +1,182 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the following additional limitation. Functionality enabled by the + * files subject to the Elastic License 2.0 may only be used in production when + * invoked by an Elasticsearch process with a license key installed that permits + * use of machine learning features. You may not use this file except in + * compliance with the Elastic License 2.0 and the foregoing additional + * limitation. + */ + +// Linux-only mechanism-probe integration test for the typed +// filesystem/network launch policy. Builds a real policy via +// buildPytorchInferenceFilesystemPolicy, runs ml_sandbox_probe inside it, +// and asserts on the probe's per-mechanism "outcome=" lines rather than +// trusting a bare exit code - a policy that merely lets the probe start +// would otherwise look identical to a correctly minimized one. +// +// This test has been reviewed against the Sandbox2 PolicyBuilder API as +// used by CSandboxForkserverSmokeTest_Linux, but still needs a real +// Linux/Sandbox2 build-and-run pass to confirm it actually passes. + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "absl/time/time.h" +#include "sandboxed_api/sandbox2/executor.h" +#include "sandboxed_api/sandbox2/result.h" +#include "sandboxed_api/sandbox2/sandbox2.h" + +#ifndef ML_SANDBOX2_PROBE_PAYLOAD +#error "ML_SANDBOX2_PROBE_PAYLOAD must be defined by lib/sandbox/unittest/CMakeLists.txt" +#endif + +namespace { + +//! Returns the outcome recorded for mechanism, or empty if the mechanism +//! line never appeared - a missing line is itself a failure (the probe +//! didn't reach that check, e.g. because it was killed earlier). +std::string outcomeFor(const std::string& resultsFileContent, const std::string& mechanism) { + std::istringstream lines{resultsFileContent}; + std::string line; + const std::string marker{"mechanism=" + mechanism + " outcome="}; + while (std::getline(lines, line)) { + const std::size_t pos = line.find(marker); + if (pos == std::string::npos) { + continue; + } + const std::size_t start = pos + marker.size(); + const std::size_t end = line.find(' ', start); + return line.substr(start, end == std::string::npos ? std::string::npos : end - start); + } + return {}; +} + +//! Returns the detail= field recorded for mechanism, or empty if the +//! mechanism line never appeared. +std::string detailFor(const std::string& resultsFileContent, const std::string& mechanism) { + std::istringstream lines{resultsFileContent}; + std::string line; + const std::string outcomeMarker{"mechanism=" + mechanism + " outcome="}; + const std::string detailMarker{" detail="}; + while (std::getline(lines, line)) { + if (line.find(outcomeMarker) == std::string::npos) { + continue; + } + const std::size_t pos = line.find(detailMarker); + return pos == std::string::npos ? std::string{} + : line.substr(pos + detailMarker.size()); + } + return {}; +} + +std::string readFileOrEmpty(const std::string& path) { + std::ifstream file{path}; + if (file.is_open() == false) { + return {}; + } + std::ostringstream contents; + contents << file.rdbuf(); + return contents.str(); +} + +} // namespace + +BOOST_AUTO_TEST_SUITE(CPytorchInferenceSandboxPolicyMechanismTest_Linux) + +BOOST_AUTO_TEST_CASE(testMinimizedPolicyEnforcesEveryMechanism) { + char tmpDirTemplate[] = "/tmp/ml_sandbox_probe_test_XXXXXX"; + char* tmpDir = ::mkdtemp(tmpDirTemplate); + BOOST_TEST_REQUIRE(tmpDir != nullptr); + // Canonicalize before building any arg path from it - same reasoning as + // CTempChildIpcFixture in the portable validator suite: on a host where + // /tmp is itself a symlink, an uncanonicalized base would make every + // literal arg path diverge from its own realpath()'d parent, tripping + // E_MutableSymlinkOrAlias for a reason that has nothing to do with what + // this test is actually exercising. + char resolvedTmpDir[PATH_MAX]; + BOOST_TEST_REQUIRE(::realpath(tmpDir, resolvedTmpDir) != nullptr); + const std::string trustedTmpDir{resolvedTmpDir}; + + BOOST_TEST_REQUIRE(::mkdir((trustedTmpDir + "/ml-child-ipc").c_str(), 0700) == 0); + const std::string childRoot{trustedTmpDir + "/ml-child-ipc/mechanism-probe-child"}; + BOOST_TEST_REQUIRE(::mkdir(childRoot.c_str(), 0700) == 0); + + const std::vector args{"--input=" + childRoot + "/input.fifo", + "--output=" + childRoot + "/output.fifo", + "--logPipe=" + childRoot + "/log.fifo"}; + const ml::sandbox::SChildIpcValidationResult validated{ + ml::sandbox::validateChildIpcLaunchSpec(trustedTmpDir, args)}; + BOOST_TEST_REQUIRE(validated.s_Ok); + + const std::string payloadPath{ML_SANDBOX2_PROBE_PAYLOAD}; + const std::vector probeArgs{payloadPath, "/run/elastic/ml-ipc"}; + + auto executor = std::make_unique(payloadPath, probeArgs); + executor->limits()->set_rlimit_cpu(10).set_walltime_limit(absl::Seconds(10)); + + sandbox2::PolicyBuilder policyBuilder{ml::sandbox::buildPytorchInferenceFilesystemPolicy( + "/usr/bin", "/usr/lib", validated.s_Spec, /*tmpfsSizeBytes=*/16 * 1024 * 1024)}; + policyBuilder.AddLibrariesForBinary(payloadPath); + // legacyBpfAllowedSyscalls() grants __NR_connect but not __NR_socket - + // real libtorch/pytorch_inference apparently also needs a bare socket() + // for its own internal socket setup, so this is likely a real gap in + // that shared declaration, not something specific to this probe. Fixing + // the shared declaration belongs with whatever change owns that file; + // granting it here, scoped to this test's own policy only, is enough to + // prove ml_sandbox_probe's network mechanisms without widening the + // production policy this test doesn't own. + policyBuilder.AllowSyscall(__NR_socket); + auto policy = policyBuilder.BuildOrDie(); + + sandbox2::Sandbox2 s2(std::move(executor), std::move(policy)); + sandbox2::Result result = s2.Run(); + + BOOST_TEST_REQUIRE(result.final_status() == sandbox2::Result::OK); + + // The child IPC directory is genuinely shared with the host, so the + // probe's results file - written from inside the sandbox to the mapped + // /run/elastic/ml-ipc path - is readable here at its host-visible + // childRoot path once the sandbox has exited. This IS the "allowed IPC + // access" proof, not a separate assertion: if the mount/policy were + // wrong, this file would never appear. + const std::string resultsContent{readFileOrEmpty(childRoot + "/results.txt")}; + BOOST_TEST_REQUIRE(resultsContent.empty() == false); + BOOST_TEST_REQUIRE(resultsContent.find("reached=true") != std::string::npos); + + BOOST_REQUIRE_EQUAL(outcomeFor(resultsContent, "ipc_readwrite"), "allowed"); + BOOST_REQUIRE_EQUAL(outcomeFor(resultsContent, "host_read_etc_shadow"), "denied"); + BOOST_REQUIRE_EQUAL(outcomeFor(resultsContent, "private_tmpfs_write"), "allowed"); + BOOST_REQUIRE_EQUAL(outcomeFor(resultsContent, "external_egress"), "denied"); + + // Mount conformance: /etc must list only allowlistedEtcFiles() (5 entries) + // plus "." and "..", never a full directory bind. A regression back to + // AddDirectory("/etc", true) would spike this into the dozens/hundreds, + // so an upper bound catches it without hard-coding the exact count. + BOOST_REQUIRE_EQUAL(outcomeFor(resultsContent, "etc_enumeration"), "counted"); + BOOST_TEST_REQUIRE(std::stoi(detailFor(resultsContent, "etc_enumeration")) <= 10); + + BOOST_REQUIRE_EQUAL(outcomeFor(resultsContent, "pid_namespace"), "namespaced"); + BOOST_REQUIRE_EQUAL(outcomeFor(resultsContent, "loopback_reachable"), "ok"); + + ::unlink((childRoot + "/probe.txt").c_str()); + ::unlink((childRoot + "/results.txt").c_str()); + ::rmdir(childRoot.c_str()); + ::rmdir((trustedTmpDir + "/ml-child-ipc").c_str()); + ::rmdir(trustedTmpDir.c_str()); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/lib/sandbox/unittest/CPytorchInferenceSandboxPolicyTest.cc b/lib/sandbox/unittest/CPytorchInferenceSandboxPolicyTest.cc new file mode 100644 index 000000000..60c8e86ae --- /dev/null +++ b/lib/sandbox/unittest/CPytorchInferenceSandboxPolicyTest.cc @@ -0,0 +1,266 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the following additional limitation. Functionality enabled by the + * files subject to the Elastic License 2.0 may only be used in production when + * invoked by an Elasticsearch process with a license key installed that permits + * use of machine learning features. You may not use this file except in + * compliance with the Elastic License 2.0 and the foregoing additional + * limitation. + */ + +// Exercises validateChildIpcLaunchSpec against the pinned child-root +// contract. This suite needs only realpath()/mkdtemp()/mkdir()/symlink(), not Sandbox2 +// itself, so it runs on every POSIX ml-cpp CI platform (Linux and macOS), +// not just Linux - but not Windows, which has none of those APIs; see +// lib/sandbox/unittest/CMakeLists.txt's NOT WIN32 guard. + +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +//! Creates trustedTmpDir/ml-child-ipc/ (mode 0700), mirroring the +//! native controller's pre-launch creation step, and returns the +//! *canonical* trusted base so literal test paths built from it never +//! diverge from realpath() output on hosts where /tmp is itself a symlink +//! (e.g. macOS's /tmp -> /private/tmp) - that divergence is a real +//! condition (E_MutableSymlinkOrAlias) this suite tests deliberately, so +//! setup must not trigger it by accident. +class CTempChildIpcFixture { +public: + explicit CTempChildIpcFixture(const std::string& childId) + : m_ChildId(childId) { + char pathTemplate[] = "/tmp/ml_sandbox_policy_test_XXXXXX"; + char* created = ::mkdtemp(pathTemplate); + BOOST_TEST_REQUIRE(created != nullptr); + m_LiteralBase.assign(created); + + char resolved[PATH_MAX]; + BOOST_TEST_REQUIRE(::realpath(m_LiteralBase.c_str(), resolved) != nullptr); + m_CanonicalBase.assign(resolved); + + BOOST_TEST_REQUIRE(::mkdir((m_CanonicalBase + "/ml-child-ipc").c_str(), 0700) == 0); + m_ChildRoot = m_CanonicalBase + "/ml-child-ipc/" + m_ChildId; + BOOST_TEST_REQUIRE(::mkdir(m_ChildRoot.c_str(), 0700) == 0); + } + + ~CTempChildIpcFixture() { + ::rmdir(m_ChildRoot.c_str()); + ::rmdir((m_CanonicalBase + "/ml-child-ipc").c_str()); + if (m_LiteralBase != m_CanonicalBase) { + ::rmdir(m_LiteralBase.c_str()); + } + ::rmdir(m_CanonicalBase.c_str()); + } + + const std::string& canonicalTrustedBase() const { return m_CanonicalBase; } + const std::string& childRoot() const { return m_ChildRoot; } + +private: + std::string m_ChildId; + std::string m_LiteralBase; + std::string m_CanonicalBase; + std::string m_ChildRoot; +}; + +} // namespace + +BOOST_AUTO_TEST_SUITE(CPytorchInferenceSandboxPolicyTest) + +BOOST_AUTO_TEST_CASE(testAcceptsAllFourPathOptionsUnderPinnedChildRoot) { + CTempChildIpcFixture fixture{"child-1"}; + const std::vector args{ + "--input=" + fixture.childRoot() + "/input.fifo", + "--output=" + fixture.childRoot() + "/output.fifo", + "--restore=" + fixture.childRoot() + "/restore.fifo", + "--logPipe=" + fixture.childRoot() + "/log.fifo", + "--someScalarOption=not-a-path", + }; + + const ml::sandbox::SChildIpcValidationResult result{ + ml::sandbox::validateChildIpcLaunchSpec(fixture.canonicalTrustedBase(), args)}; + + BOOST_TEST_REQUIRE(result.s_Ok); + BOOST_TEST_REQUIRE(result.s_Rejected.empty()); + BOOST_REQUIRE_EQUAL(result.s_Spec.s_ChildId, "child-1"); + BOOST_REQUIRE_EQUAL(result.s_Spec.s_ChildIpcRoot, fixture.childRoot()); + BOOST_REQUIRE_EQUAL(result.s_Spec.s_PipePaths.size(), 4); +} + +BOOST_AUTO_TEST_CASE(testNoPathOptionsIsNotOk) { + const ml::sandbox::SChildIpcValidationResult result{ + ml::sandbox::validateChildIpcLaunchSpec("/tmp", {"--foo=bar"})}; + + BOOST_TEST_REQUIRE(result.s_Ok == false); + BOOST_TEST_REQUIRE(result.s_Spec.s_ChildId.empty()); +} + +BOOST_AUTO_TEST_CASE(testRejectsRelativePath) { + CTempChildIpcFixture fixture{"child-2"}; + const ml::sandbox::SChildIpcValidationResult result{ml::sandbox::validateChildIpcLaunchSpec( + fixture.canonicalTrustedBase(), {"--input=relative/input.fifo"})}; + + BOOST_TEST_REQUIRE(result.s_Ok == false); + BOOST_REQUIRE_EQUAL(result.s_Rejected.size(), 1); + BOOST_REQUIRE(result.s_Rejected[0].s_Reason == + ml::sandbox::EChildIpcPathRejection::E_NotAbsolute); +} + +BOOST_AUTO_TEST_CASE(testRejectsRootLevelPath) { + CTempChildIpcFixture fixture{"child-3"}; + const ml::sandbox::SChildIpcValidationResult result{ml::sandbox::validateChildIpcLaunchSpec( + fixture.canonicalTrustedBase(), {"--input=/input.fifo"})}; + + BOOST_TEST_REQUIRE(result.s_Ok == false); + BOOST_REQUIRE_EQUAL(result.s_Rejected.size(), 1); + BOOST_REQUIRE(result.s_Rejected[0].s_Reason == + ml::sandbox::EChildIpcPathRejection::E_RootLevelPath); +} + +BOOST_AUTO_TEST_CASE(testRejectsDotDotEscape) { + CTempChildIpcFixture fixture{"child-4"}; + const std::string escapingPath{fixture.childRoot() + "/../../../etc/passwd"}; + const ml::sandbox::SChildIpcValidationResult result{ml::sandbox::validateChildIpcLaunchSpec( + fixture.canonicalTrustedBase(), {"--input=" + escapingPath})}; + + BOOST_TEST_REQUIRE(result.s_Ok == false); + BOOST_REQUIRE_EQUAL(result.s_Rejected.size(), 1); + BOOST_REQUIRE(result.s_Rejected[0].s_Reason == + ml::sandbox::EChildIpcPathRejection::E_ContainsDotDot); +} + +BOOST_AUTO_TEST_CASE(testRejectsPathOutsideTrustedBase) { + CTempChildIpcFixture fixture{"child-5"}; + const ml::sandbox::SChildIpcValidationResult result{ml::sandbox::validateChildIpcLaunchSpec( + fixture.canonicalTrustedBase(), {"--input=/var/tmp/not-under-tmpdir/input.fifo"})}; + + BOOST_TEST_REQUIRE(result.s_Ok == false); + BOOST_REQUIRE_EQUAL(result.s_Rejected.size(), 1); + BOOST_REQUIRE(result.s_Rejected[0].s_Reason == + ml::sandbox::EChildIpcPathRejection::E_CanonicalizationFailed || + result.s_Rejected[0].s_Reason == + ml::sandbox::EChildIpcPathRejection::E_OutsideTrustedBase); +} + +BOOST_AUTO_TEST_CASE(testRejectsWrongDepthDirectChildOfTrustedBase) { + CTempChildIpcFixture fixture{"child-6"}; + // Direct child of $TMPDIR (missing the ml-child-ipc intermediate + // directory) must fail, not silently be accepted as "close enough". + const std::string tooShallow{fixture.canonicalTrustedBase() + "/input.fifo"}; + + const ml::sandbox::SChildIpcValidationResult result{ml::sandbox::validateChildIpcLaunchSpec( + fixture.canonicalTrustedBase(), {"--input=" + tooShallow})}; + + BOOST_TEST_REQUIRE(result.s_Ok == false); + BOOST_REQUIRE_EQUAL(result.s_Rejected.size(), 1); + BOOST_REQUIRE(result.s_Rejected[0].s_Reason == ml::sandbox::EChildIpcPathRejection::E_RootLevelPath || + result.s_Rejected[0].s_Reason == + ml::sandbox::EChildIpcPathRejection::E_WrongDepth); +} + +BOOST_AUTO_TEST_CASE(testRejectsTooDeepNestingUnderChildId) { + CTempChildIpcFixture fixture{"child-7"}; + const std::string nestedDir{fixture.childRoot() + "/nested"}; + BOOST_TEST_REQUIRE(::mkdir(nestedDir.c_str(), 0700) == 0); + + const ml::sandbox::SChildIpcValidationResult result{ml::sandbox::validateChildIpcLaunchSpec( + fixture.canonicalTrustedBase(), {"--input=" + nestedDir + "/input.fifo"})}; + + BOOST_TEST_REQUIRE(result.s_Ok == false); + BOOST_REQUIRE_EQUAL(result.s_Rejected.size(), 1); + BOOST_REQUIRE(result.s_Rejected[0].s_Reason == + ml::sandbox::EChildIpcPathRejection::E_WrongDepth); + + ::rmdir(nestedDir.c_str()); +} + +BOOST_AUTO_TEST_CASE(testRejectsDuplicateLiteralArgument) { + CTempChildIpcFixture fixture{"child-8"}; + const std::string arg{"--input=" + fixture.childRoot() + "/input.fifo"}; + + const ml::sandbox::SChildIpcValidationResult result{ml::sandbox::validateChildIpcLaunchSpec( + fixture.canonicalTrustedBase(), {arg, arg})}; + + BOOST_TEST_REQUIRE(result.s_Ok == false); + BOOST_REQUIRE_EQUAL(result.s_Rejected.size(), 1); + BOOST_REQUIRE(result.s_Rejected[0].s_Reason == + ml::sandbox::EChildIpcPathRejection::E_Duplicate); +} + +BOOST_AUTO_TEST_CASE(testRejectsMutableSymlinkAlias) { + CTempChildIpcFixture fixture{"child-9"}; + const std::string aliasPath{fixture.canonicalTrustedBase() + "/ml-child-ipc/child-9-alias"}; + BOOST_TEST_REQUIRE(::symlink(fixture.childRoot().c_str(), aliasPath.c_str()) == 0); + + const ml::sandbox::SChildIpcValidationResult result{ml::sandbox::validateChildIpcLaunchSpec( + fixture.canonicalTrustedBase(), {"--input=" + aliasPath + "/input.fifo"})}; + + BOOST_TEST_REQUIRE(result.s_Ok == false); + BOOST_REQUIRE_EQUAL(result.s_Rejected.size(), 1); + BOOST_REQUIRE(result.s_Rejected[0].s_Reason == + ml::sandbox::EChildIpcPathRejection::E_MutableSymlinkOrAlias); + + ::unlink(aliasPath.c_str()); +} + +BOOST_AUTO_TEST_CASE(testRejectsChildIdMismatchAcrossOptions) { + // Both children must sit under the *same* trusted base for this to + // actually exercise E_ChildIdMismatch - two independent + // CTempChildIpcFixture instances each mkdtemp their own unrelated base, + // so a second-fixture path would hit E_OutsideTrustedBase/E_WrongDepth + // first and never reach the child-id comparison at all. + CTempChildIpcFixture fixtureA{"child-10a"}; + const std::string siblingChildRoot{fixtureA.canonicalTrustedBase() + "/ml-child-ipc/child-10b"}; + BOOST_TEST_REQUIRE(::mkdir(siblingChildRoot.c_str(), 0700) == 0); + + const ml::sandbox::SChildIpcValidationResult result{ml::sandbox::validateChildIpcLaunchSpec( + fixtureA.canonicalTrustedBase(), + {"--input=" + fixtureA.childRoot() + "/input.fifo", + "--output=" + siblingChildRoot + "/output.fifo"})}; + + BOOST_TEST_REQUIRE(result.s_Ok == false); + BOOST_REQUIRE_EQUAL(result.s_Rejected.size(), 1); + BOOST_REQUIRE(result.s_Rejected[0].s_Reason == + ml::sandbox::EChildIpcPathRejection::E_ChildIdMismatch); + + ::rmdir(siblingChildRoot.c_str()); +} + +BOOST_AUTO_TEST_CASE(testIgnoresScalarOptionsAsCandidatePaths) { + CTempChildIpcFixture fixture{"child-11"}; + const ml::sandbox::SChildIpcValidationResult result{ml::sandbox::validateChildIpcLaunchSpec( + fixture.canonicalTrustedBase(), {"--input=" + fixture.childRoot() + "/input.fifo", + "--modelId=../../../etc/passwd", "--inputIsPipe"})}; + + BOOST_TEST_REQUIRE(result.s_Ok); + BOOST_TEST_REQUIRE(result.s_Rejected.empty()); +} + +BOOST_AUTO_TEST_CASE(testRejectsEmptyValueForRecognizedPathOptionEvenAmongValidOnes) { + CTempChildIpcFixture fixture{"child-12"}; + // "--input=" (empty value) must be rejected, not silently skipped as if + // the option were absent - even though "--output=..." for the same + // child is otherwise valid. A prior version of the parser treated an + // empty value identically to a missing "=" and never reached the + // value.empty() rejection branch below it. + const ml::sandbox::SChildIpcValidationResult result{ml::sandbox::validateChildIpcLaunchSpec( + fixture.canonicalTrustedBase(), + {"--input=", "--output=" + fixture.childRoot() + "/output.fifo"})}; + + BOOST_TEST_REQUIRE(result.s_Ok == false); + BOOST_REQUIRE_EQUAL(result.s_Rejected.size(), 1); + BOOST_REQUIRE_EQUAL(result.s_Rejected[0].s_Arg, "--input="); + BOOST_REQUIRE(result.s_Rejected[0].s_Reason == + ml::sandbox::EChildIpcPathRejection::E_NotAbsolute); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/lib/sandbox/unittest/payloads/ml_sandbox_probe.cc b/lib/sandbox/unittest/payloads/ml_sandbox_probe.cc new file mode 100644 index 000000000..e418e2d8e --- /dev/null +++ b/lib/sandbox/unittest/payloads/ml_sandbox_probe.cc @@ -0,0 +1,198 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the following additional limitation. Functionality enabled by the + * files subject to the Elastic License 2.0 may only be used in production when + * invoked by an Elasticsearch process with a license key installed that permits + * use of machine learning features. You may not use this file except in + * compliance with the Elastic License 2.0 and the foregoing additional + * limitation. + */ + +// Purpose-built allowlisted payload for the typed filesystem/network launch +// policy's mechanism probe. Runs *inside* the sandbox under the policy built +// by buildPytorchInferenceFilesystemPolicy and prints +// one "mechanism=... outcome=..." line per check to stdout, which the +// controller-side test (CPytorchInferenceSandboxPolicyMechanismTest_Linux) +// asserts on directly - a wrong-but-still-startable policy would otherwise +// look identical to a correct one if the test only checked the exit code. +// Deliberately dependency-free, like sandbox_smoke_payload.cc: no ml-cpp +// library dependencies, no policy of its own. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +//! File descriptor for the results file this probe writes into the mapped +//! per-child IPC directory. The host-side test reads that file directly +//! from the *host* path after the sandbox exits - the IPC directory is +//! genuinely shared, so this doubles as the "allowed IPC access" proof and +//! as this probe's only result channel (no stdout capture plumbing exists +//! yet; that lands once a real process spawner owns pipe plumbing for +//! sandboxed children). +int g_ResultsFd = -1; + +void report(const char* mechanism, const char* outcome, const std::string& detail = "") { + std::printf("ml_sandbox_probe: mechanism=%s outcome=%s detail=%s\n", + mechanism, outcome, detail.c_str()); + std::fflush(stdout); + if (g_ResultsFd >= 0) { + std::string line{std::string("mechanism=") + mechanism + + " outcome=" + outcome + " detail=" + detail + "\n"}; + ::write(g_ResultsFd, line.c_str(), line.size()); + } +} + +} // namespace + +int main(int argc, char** argv) { + if (argc < 2) { + std::fprintf(stderr, "usage: ml_sandbox_probe \n"); + return EXIT_FAILURE; + } + const std::string ipcDir{argv[1]}; + + g_ResultsFd = ::open((ipcDir + "/results.txt").c_str(), + O_CREAT | O_WRONLY | O_TRUNC, 0600); + + std::printf("ml_sandbox_probe: reached\n"); + std::fflush(stdout); + if (g_ResultsFd >= 0) { + const std::string reachedLine{"reached=true\n"}; + ::write(g_ResultsFd, reachedLine.c_str(), reachedLine.size()); + } + + // Allowed IPC access (positive control): write then read back a file + // inside the mapped per-child IPC directory. + const std::string ipcFile{ipcDir + "/probe.txt"}; + int writeFd = ::open(ipcFile.c_str(), O_CREAT | O_WRONLY, 0600); + if (writeFd >= 0) { + ::write(writeFd, "probe", 5); + ::close(writeFd); + int readFd = ::open(ipcFile.c_str(), O_RDONLY); + char buf[8]{}; + const bool readBack = readFd >= 0 && ::read(readFd, buf, sizeof(buf)) == 5 && + std::strncmp(buf, "probe", 5) == 0; + if (readFd >= 0) { + ::close(readFd); + } + report("ipc_readwrite", readBack ? "allowed" : "denied"); + } else { + report("ipc_readwrite", "denied", std::strerror(errno)); + } + + // Denied host read (negative control): /etc/shadow must not be + // readable even though narrow, individually justified /etc files are + // allowlisted (allowlistedEtcFiles()). + int shadowFd = ::open("/etc/shadow", O_RDONLY); + if (shadowFd < 0) { + report("host_read_etc_shadow", "denied", std::strerror(errno)); + } else { + ::close(shadowFd); + report("host_read_etc_shadow", "allowed"); + } + + // Private tmpfs: writability alone doesn't prove /tmp is a private + // tmpfs rather than a host bind - a regressed policy that AddDirectory's + // the real host /tmp would still pass a plain write check on any + // world-writable host. statfs()'s f_type is the actual mechanism + // distinguishing tmpfs from a bind-mounted host directory. + struct statfs tmpStatfs {}; + const bool isTmpfs = ::statfs("/tmp", &tmpStatfs) == 0 && tmpStatfs.f_type == TMPFS_MAGIC; + const std::string privateTmpFile{"/tmp/ml_sandbox_probe_private_tmp_test"}; + int tmpFd = ::open(privateTmpFile.c_str(), O_CREAT | O_WRONLY, 0600); + if (tmpFd >= 0) { + ::close(tmpFd); + ::unlink(privateTmpFile.c_str()); + report("private_tmpfs_write", isTmpfs ? "allowed" : "denied", + isTmpfs ? "" : "writable but not tmpfs-backed"); + } else { + report("private_tmpfs_write", "denied", std::strerror(errno)); + } + + // Mount enumeration conformance: /etc must list only the + // allowlisted files, never a full directory bind. + DIR* etcDir = ::opendir("/etc"); + if (etcDir != nullptr) { + int entryCount = 0; + while (::readdir(etcDir) != nullptr) { + ++entryCount; + } + ::closedir(etcDir); + report("etc_enumeration", "counted", std::to_string(entryCount)); + } else { + report("etc_enumeration", "denied", std::strerror(errno)); + } + + // Private PID namespace: this process should be (close to) the + // sandbox's own init, not a real-looking host PID. + report("pid_namespace", (::getpid() <= 2) ? "namespaced" : "not_namespaced", + std::to_string(::getpid())); + + // External egress denial (negative control): an outbound connect to + // a guaranteed non-routable test address (TEST-NET-1, RFC 5737) must + // fail - Sandbox2's network namespace has no route out. Using a + // non-routable address instead of a real host keeps this check + // hermetic and independent of network availability in CI. + int egressSocket = ::socket(AF_INET, SOCK_STREAM, 0); + if (egressSocket >= 0) { + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(80); + ::inet_pton(AF_INET, "192.0.2.1", &addr.sin_addr); + const int rc = ::connect(egressSocket, reinterpret_cast(&addr), + sizeof(addr)); + const int connectErrno = errno; + // A namespace with no route out fails synchronously with + // ENETUNREACH/EHOSTUNREACH before any packet leaves the sandbox. + // ECONNREFUSED would mean a packet actually reached something that + // sent back RST - a routing leak, not isolation - so only the + // no-route errnos count as "denied"; anything else (including + // success) is reported "allowed" to keep that distinction visible. + const bool denied = rc != 0 && (connectErrno == ENETUNREACH || + connectErrno == EHOSTUNREACH); + report("external_egress", denied ? "denied" : "allowed", std::strerror(connectErrno)); + ::close(egressSocket); + } else { + report("external_egress", "denied", std::strerror(errno)); + } + + // Local operation success (positive control): loopback must remain + // reachable at the network-namespace level. Connection-refused (nobody + // listening on this port) still counts as "reachable" - only a + // namespace-level error (e.g. ENETUNREACH) means loopback itself broke. + int loopbackSocket = ::socket(AF_INET, SOCK_STREAM, 0); + if (loopbackSocket >= 0) { + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(1); + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + const int rc = ::connect(loopbackSocket, + reinterpret_cast(&addr), sizeof(addr)); + const bool loopbackReachable = rc == 0 || errno == ECONNREFUSED; + report("loopback_reachable", loopbackReachable ? "ok" : "broken", + std::strerror(errno)); + ::close(loopbackSocket); + } else { + report("loopback_reachable", "broken", std::strerror(errno)); + } + + std::printf("ml_sandbox_probe: done\n"); + std::fflush(stdout); + if (g_ResultsFd >= 0) { + ::close(g_ResultsFd); + } + return EXIT_SUCCESS; +}