diff --git a/bin/action_runner.ml b/bin/action_runner.ml index 9d550e08045..28106cd9024 100644 --- a/bin/action_runner.ml +++ b/bin/action_runner.ml @@ -2,26 +2,44 @@ open Import let name = Action_runner_name.of_string "action-runner" -let find_in_path_exn prog = - match Bin.which ~path:(Env_path.path Env.initial) prog with - | Some path -> path - | None -> User_error.raise [ Pp.textf "unable to find %s in PATH" prog ] +module Sandbox_actions_backend = struct + type t = + | Auto + | Bwrap + | Landlock + + let all = [ "auto", Auto; "bwrap", Bwrap; "landlock", Landlock ] + let equal = Poly.equal + let default = Auto +end + +let dune_prog () = Util.resolve_program_path Sys.executable_name + +let bwrap_command worker_argv = + let { Bwrap.prog; argv } = + Bwrap.wrap ~cwd:(Path.to_absolute_filename Path.root) worker_argv + in + prog, argv ;; -let has_directory_component prog = - String.exists prog ~f:(function - | '/' | '\\' -> true - | _ -> false) +let landlock_command ~dune_prog worker_argv = + let { Landlock_command.prog; argv } = + Landlock_command.wrap_exn ~dune_prog worker_argv + in + prog, argv ;; -let dune_prog () = - let prog = Sys.executable_name in - if Filename.is_relative prog && not (has_directory_component prog) - then find_in_path_exn prog - else Path.of_filename_relative_to_initial_cwd prog +let sandbox_actions_command ~backend ~dune_prog worker_argv = + match (backend : Sandbox_actions_backend.t) with + | Auto -> + (match Landlock_command.wrap ~dune_prog worker_argv with + | Some { Landlock_command.prog; argv } -> prog, argv + | None -> bwrap_command worker_argv) + | Bwrap -> bwrap_command worker_argv + | Landlock -> landlock_command ~dune_prog worker_argv ;; -let create ~where ~config ~sandbox_actions = +let create ~where ~config ~sandbox_actions ~sandbox_actions_backend = let pid = let env = let jobs = @@ -47,11 +65,7 @@ let create ~where ~config ~sandbox_actions = ] in if sandbox_actions - then ( - let { Bwrap.prog; argv } = - Bwrap.wrap ~cwd:(Path.to_absolute_filename Path.root) worker_argv - in - prog, argv) + then sandbox_actions_command ~backend:sandbox_actions_backend ~dune_prog worker_argv else Path.to_string dune_prog, worker_argv in let argv = diff --git a/bin/action_runner.mli b/bin/action_runner.mli index 8875be60af6..c971af4c5a2 100644 --- a/bin/action_runner.mli +++ b/bin/action_runner.mli @@ -1,9 +1,21 @@ open Import +module Sandbox_actions_backend : sig + type t = + | Auto + | Bwrap + | Landlock + + val all : (string * t) list + val equal : t -> t -> bool + val default : t +end + val create : where:Dune_rpc.Where.t -> config:Dune_config.t -> sandbox_actions:bool + -> sandbox_actions_backend:Sandbox_actions_backend.t -> Dune_engine.Action_runner.t val start_worker : name:string -> where:string -> trace_fd:string option -> unit Fiber.t diff --git a/bin/bwrap.ml b/bin/bwrap.ml index 5d387ddcaec..697b45f0f14 100644 --- a/bin/bwrap.ml +++ b/bin/bwrap.ml @@ -5,15 +5,9 @@ type command = ; argv : string list } -let find_in_path_exn prog = - match Bin.which ~path:(Env_path.path Env.initial) prog with - | Some path -> path - | None -> User_error.raise [ Pp.textf "unable to find %s in PATH" prog ] -;; - let prog () = match Platform.OS.value with - | Linux -> find_in_path_exn "bwrap" + | Linux -> Util.find_in_path_exn "bwrap" | _ -> User_error.raise [ Pp.text "--sandbox-actions is currently only supported on Linux" ] ;; diff --git a/bin/common.ml b/bin/common.ml index f2ea2dabbd6..629e1a685cb 100644 --- a/bin/common.ml +++ b/bin/common.ml @@ -625,6 +625,7 @@ module Builder = struct ; target_exec : string option ; action_runner : bool ; sandbox_actions : bool + ; sandbox_actions_backend : Action_runner.Sandbox_actions_backend.t } let set_no_build t no_build = { t with no_build } @@ -961,8 +962,24 @@ module Builder = struct ~docs ~doc: (Some - "Run spawned build processes in an external dune action runner wrapped \ - with bubblewrap.")) + "Run spawned build processes in an external dune action runner with \ + shared-cache writes blocked.")) + and+ sandbox_actions_backend = + Arg.( + value + & opt + (enum Action_runner.Sandbox_actions_backend.all) + Action_runner.Sandbox_actions_backend.default + & info + [ "sandbox-actions-backend" ] + ~docs + ~docv:"BACKEND" + ~doc: + (Some + "Select the mechanism used by --sandbox-actions to block writes to the \ + shared cache. $(b,auto) uses Landlock on Linux when available, and \ + falls back to bubblewrap. $(b,landlock) requires Landlock. $(b,bwrap) \ + requires bubblewrap.")) and+ action_runner = Arg.( value @@ -1017,6 +1034,7 @@ module Builder = struct ; target_exec ; action_runner ; sandbox_actions + ; sandbox_actions_backend } ;; @@ -1058,6 +1076,7 @@ module Builder = struct ; target_exec ; action_runner ; sandbox_actions + ; sandbox_actions_backend } = No_build.equal t.no_build no_build @@ -1099,6 +1118,9 @@ module Builder = struct && Option.equal String.equal t.target_exec target_exec && Bool.equal t.action_runner action_runner && Bool.equal t.sandbox_actions sandbox_actions + && Action_runner.Sandbox_actions_backend.equal + t.sandbox_actions_backend + sandbox_actions_backend ;; end @@ -1391,7 +1413,8 @@ let init_with_root_and_rpc ~(root : Workspace_root.t) ~rpc_build (builder : Buil (Action_runner.create ~where:(Lazy.force where) ~config - ~sandbox_actions:c.builder.sandbox_actions) + ~sandbox_actions:c.builder.sandbox_actions + ~sandbox_actions_backend:c.builder.sandbox_actions_backend) else None) in let rpc = diff --git a/bin/internal.ml b/bin/internal.ml index 68d987678e6..88e475a0c51 100644 --- a/bin/internal.ml +++ b/bin/internal.ml @@ -230,6 +230,7 @@ let group = ; Internal_digest_db.command ; Internal_action_runner.group ; Bwrap.With_bwrap.command + ; Landlock_command.With_landlock.command ; latest_lang_version ; bootstrap_info ; Sexp_pp.command diff --git a/bin/landlock_command.ml b/bin/landlock_command.ml new file mode 100644 index 00000000000..eab2b3c6588 --- /dev/null +++ b/bin/landlock_command.ml @@ -0,0 +1,48 @@ +open Import + +type command = + { prog : string + ; argv : string list + } + +let available = Stdune.Landlock.available + +let restrict_shared_cache_to_read_only () = + let build_cache_dir = Lazy.force Dune_cache.Layout.build_cache_dir in + Path.mkdir_p build_cache_dir; + let deny_write = + [ Path.to_absolute_filename build_cache_dir |> Path.External.of_string ] + in + Stdune.Landlock.Policy.create ~deny_write ~allow_write:[] + |> Stdune.Landlock.restrict_self +;; + +let wrap ~dune_prog argv = + if available () + then ( + let prog = Path.to_string dune_prog in + Some { prog; argv = [ prog; "internal"; "with-landlock"; "--" ] @ argv }) + else None +;; + +let wrap_exn ~dune_prog argv = + match wrap ~dune_prog argv with + | Some command -> command + | None -> User_error.raise [ Pp.text "Landlock is not available on this system" ] +;; + +module With_landlock = struct + let command = + let doc = "Run a command under Dune's Landlock wrapper." in + let info = Cmd.info "with-landlock" ~doc in + let term = + let+ argv = Arg.(value & pos_all string [] (info [] ~docv:"COMMAND" ~doc:None)) in + match argv with + | [] -> User_error.raise [ Pp.text "missing command after --" ] + | prog :: args -> + restrict_shared_cache_to_read_only (); + Proc.restore_cwd_and_execve (Util.resolve_prog prog) args ~env:Env.initial + in + Cmd.v info term + ;; +end diff --git a/bin/landlock_command.mli b/bin/landlock_command.mli new file mode 100644 index 00000000000..528bb5aabf2 --- /dev/null +++ b/bin/landlock_command.mli @@ -0,0 +1,14 @@ +open Import + +type command = + { prog : string + ; argv : string list + } + +val available : unit -> bool +val wrap : dune_prog:Path.t -> string list -> command option +val wrap_exn : dune_prog:Path.t -> string list -> command + +module With_landlock : sig + val command : unit Cmd.t +end diff --git a/bin/util.ml b/bin/util.ml index 439163ab491..383e86592a6 100644 --- a/bin/util.ml +++ b/bin/util.ml @@ -1,5 +1,27 @@ open Import +let find_in_path_exn prog = + match Bin.which ~path:(Env_path.path Env.initial) prog with + | Some path -> path + | None -> User_error.raise [ Pp.textf "unable to find %s in PATH" prog ] +;; + +let has_directory_component prog = + String.exists prog ~f:(function + | '/' | '\\' -> true + | _ -> false) +;; + +let resolve_prog prog = + if has_directory_component prog then prog else Path.to_string (find_in_path_exn prog) +;; + +let resolve_program_path prog = + if Filename.is_relative prog && not (has_directory_component prog) + then find_in_path_exn prog + else Path.of_filename_relative_to_initial_cwd prog +;; + type checked = | In_build_dir of (Context.t * Path.Source.t) | In_private_context of Path.Build.t diff --git a/bin/util.mli b/bin/util.mli index 84d4a5548c1..dacc4a23666 100644 --- a/bin/util.mli +++ b/bin/util.mli @@ -1,5 +1,9 @@ open Import +val find_in_path_exn : string -> Path.t +val resolve_prog : string -> string +val resolve_program_path : string -> Path.t + type checked = | In_build_dir of (Context.t * Path.Source.t) | In_private_context of Path.Build.t diff --git a/otherlibs/stdune/src/dune b/otherlibs/stdune/src/dune index 27e4e3a0a5c..15c42a7cb8e 100644 --- a/otherlibs/stdune/src/dune +++ b/otherlibs/stdune/src/dune @@ -18,7 +18,7 @@ (:include ../flags/sexp)) (foreign_stubs (language c) - (names spawn_stubs) + (names landlock_stubs spawn_stubs) (flags (:standard (:include spawn_flags.sexp)))) diff --git a/otherlibs/stdune/src/landlock.ml b/otherlibs/stdune/src/landlock.ml new file mode 100644 index 00000000000..c5484e495f2 --- /dev/null +++ b/otherlibs/stdune/src/landlock.ml @@ -0,0 +1,237 @@ +module Raw = struct + external abi_version : unit -> int = "dune_landlock_abi_version" + external write_access_rights : int -> int64 = "dune_landlock_write_access_rights" + + external file_write_access_rights + : int + -> int64 + = "dune_landlock_file_write_access_rights" + + external create_ruleset : int64 -> Unix.file_descr = "dune_landlock_create_ruleset" + + external add_rule + : Unix.file_descr + -> string + -> int64 + -> unit + = "dune_landlock_add_rule" + + external restrict_self : Unix.file_descr -> unit = "dune_landlock_restrict_self" +end + +let minimum_abi = 3 + +module Access = struct + type t = + { directory : int64 + ; file : int64 + } + + let for_abi abi = + { directory = Raw.write_access_rights abi; file = Raw.file_write_access_rights abi } + ;; + + let handled_raw t = t.directory + + let raw_for_file_kind t = function + | Unix.S_DIR -> t.directory + | S_REG | S_CHR | S_BLK | S_LNK | S_FIFO | S_SOCK -> t.file + ;; +end + +let available () = + match Raw.abi_version () with + | abi -> abi >= minimum_abi && not (Int64.equal (Raw.write_access_rights abi) 0L) + | exception Unix.Unix_error _ -> false +;; + +module Ruleset0 = struct + type t = + { fd : Fd.t + ; access : Access.t + } + + let create_empty () = + let access = Access.for_abi (Raw.abi_version ()) in + let fd = Raw.create_ruleset (Access.handled_raw access) in + (* Close the ruleset in unrelated children that may [execve] while this fd + is open. The intended child uses and closes it before its own exec. *) + Unix.set_close_on_exec fd; + { fd = Fd.unsafe_of_unix_file_descr fd; access } + ;; + + let add t ~path = + let path = Path.External.to_string path in + let allowed_access = + let { Unix.st_kind; _ } = Unix.stat path in + Access.raw_for_file_kind t.access st_kind + in + Raw.add_rule (Fd.unsafe_to_unix_file_descr t.fd) path allowed_access + ;; + + let file_descr t = Fd.unsafe_to_unix_file_descr t.fd + let close t = Fd.close t.fd + let restrict_self t = Raw.restrict_self (file_descr t) +end + +module Path_policy = struct + type t = + { denied : Path.External.t list + ; allowed : Path.External.t list + } + + let realpath_exn path = + Unix.realpath (Path.External.to_string path) |> Path.External.of_string + ;; + + let is_descendant path ~of_ = + Path.External.equal path of_ || Path.External.is_descendant path ~of_ + ;; + + let is_strict_descendant path ~of_ = + (not (Path.External.equal path of_)) && is_descendant path ~of_ + ;; + + let resolve path = + match realpath_exn path with + | path -> path + | exception Unix.Unix_error (error, _, _) -> + User_error.raise + [ Pp.textf + "unable to resolve Landlock policy path %s: %s" + (Path.External.to_string_maybe_quoted path) + (Unix.error_message error) + ] + ;; + + let validate_allowed_path ~denied allowed = + if not (List.exists denied ~f:(fun deny -> is_strict_descendant allowed ~of_:deny)) + then + User_error.raise + [ Pp.textf + "allowed path %s must be strictly inside a denied path" + (Path.External.to_string_maybe_quoted allowed) + ]; + match List.find denied ~f:(fun deny -> is_descendant deny ~of_:allowed) with + | None -> () + | Some deny -> + User_error.raise + [ Pp.textf + "allowed path %s must not cover denied path %s" + (Path.External.to_string_maybe_quoted allowed) + (Path.External.to_string_maybe_quoted deny) + ] + ;; + + let create ~denied ~allowed = + if List.is_empty denied + then Code_error.raise "Landlock.Policy.create: denied paths must not be empty" []; + let denied = List.map denied ~f:resolve in + let allowed = List.map allowed ~f:resolve in + List.iter allowed ~f:(validate_allowed_path ~denied); + { denied; allowed } + ;; + + let add_best_effort_rule ruleset path = + match Ruleset0.add ruleset ~path with + | () -> () + | exception Unix.Unix_error ((ENOENT | EACCES | EPERM | ENOTDIR | ELOOP), _, _) -> () + ;; + + let add_required_rule ruleset path = + match Ruleset0.add ruleset ~path with + | () -> () + | exception Unix.Unix_error (err, _, _) -> + User_error.raise + [ Pp.textf + "failed to add required Landlock rule for %s: %s" + (Path.External.to_string_maybe_quoted path) + (Unix.error_message err) + ] + ;; + + let stat_key path = + match Unix.stat (Path.External.to_string path) with + | { Unix.st_dev; st_ino; _ } -> Some (st_dev, st_ino) + | exception Unix.Unix_error _ -> None + ;; + + let is_protected_alias path ~protected_ancestors = + match stat_key path with + | None -> false + | Some key -> + List.exists protected_ancestors ~f:(Tuple.T2.equal Int.equal Int.equal key) + ;; + + let path_and_ancestors path = + let rec loop path acc = + match Path.External.parent path with + | None -> path :: acc + | Some parent -> loop parent (path :: acc) + in + loop path [] + ;; + + (* Landlock rules are allow-lists. To keep denied subtrees write-protected, + allow writes to siblings of every ancestor of every protected path. Skip + lexical descendants of protected paths, paths whose realpath is at or + above any protected path, and bind-mount aliases of protected ancestors. + Explicit allow paths are then added as punch-holes. *) + let add_rules { denied; allowed } ruleset = + let skeleton = + List.concat_map denied ~f:path_and_ancestors |> Path.External.Set.of_list + in + let protected_ancestors = + Path.External.Set.to_list skeleton |> List.filter_map ~f:stat_key + in + let in_or_above_protected realpath = + List.exists denied ~f:(fun path -> + is_descendant realpath ~of_:path || is_descendant path ~of_:realpath) + in + Path.External.Set.iter skeleton ~f:(fun dir -> + match Sys.readdir (Path.External.to_string dir) with + | exception Sys_error _ -> () + | entries -> + Array.iter entries ~f:(fun entry -> + let path = Path.External.relative_fname dir (Filename.of_string_exn entry) in + if not (Path.External.Set.mem skeleton path) + then ( + match realpath_exn path with + | exception Unix.Unix_error _ -> () + | realpath -> + if + not + (in_or_above_protected realpath + || is_protected_alias path ~protected_ancestors) + then add_best_effort_rule ruleset path))); + List.iter allowed ~f:(add_required_rule ruleset) + ;; +end + +module Policy = struct + type t = Path_policy.t + + let create ~deny_write ~allow_write = + Path_policy.create ~denied:deny_write ~allowed:allow_write + ;; +end + +module Ruleset = struct + include Ruleset0 + + let create policy = + let ruleset = create_empty () in + match Path_policy.add_rules policy ruleset with + | () -> ruleset + | exception exn -> + close ruleset; + raise exn + ;; +end + +let restrict_self policy = + let ruleset = Ruleset.create policy in + Exn.protect + ~f:(fun () -> Ruleset.restrict_self ruleset) + ~finally:(fun () -> Ruleset.close ruleset) +;; diff --git a/otherlibs/stdune/src/landlock.mli b/otherlibs/stdune/src/landlock.mli new file mode 100644 index 00000000000..f0a74568f04 --- /dev/null +++ b/otherlibs/stdune/src/landlock.mli @@ -0,0 +1,33 @@ +(** Landlock filesystem policies. *) + +val available : unit -> bool + +module Policy : sig + (** A write policy satisfies these invariants: + + - there is at least one denied path; + - all paths existed and were resolved with [realpath] when the policy was + created; + - every allowed path is a strict descendant of at least one denied path; + - no allowed path is equal to or an ancestor of a denied path. + + Paths are not deduplicated. *) + type t + + val create : deny_write:Path.External.t list -> allow_write:Path.External.t list -> t +end + +module Ruleset : sig + type t + + val create : Policy.t -> t + val file_descr : t -> Unix.file_descr + val close : t -> unit + + (** Restrict the calling thread with [t]. The restriction is inherited by + subsequently created children and cannot be removed. *) + val restrict_self : t -> unit +end + +(** Create and immediately enforce [policy] on the calling thread. *) +val restrict_self : Policy.t -> unit diff --git a/otherlibs/stdune/src/landlock_stubs.c b/otherlibs/stdune/src/landlock_stubs.c new file mode 100644 index 00000000000..92283868cbe --- /dev/null +++ b/otherlibs/stdune/src/landlock_stubs.c @@ -0,0 +1,196 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include + +#include +#include + +#ifndef ENOSYS +#define ENOSYS EINVAL +#endif + +#if defined(__linux__) +#include +#include +#include +#include +#if defined(__has_include) +#if __has_include() +#define DUNE_HAS_LINUX_LANDLOCK_H 1 +#include +#endif +#endif +#endif + +#if defined(__linux__) && defined(DUNE_HAS_LINUX_LANDLOCK_H) && \ + defined(SYS_landlock_create_ruleset) && defined(SYS_landlock_add_rule) && \ + defined(SYS_landlock_restrict_self) && \ + defined(LANDLOCK_CREATE_RULESET_VERSION) && \ + defined(LANDLOCK_ACCESS_FS_WRITE_FILE) && \ + defined(LANDLOCK_ACCESS_FS_REMOVE_DIR) && \ + defined(LANDLOCK_ACCESS_FS_REMOVE_FILE) && \ + defined(LANDLOCK_ACCESS_FS_MAKE_CHAR) && \ + defined(LANDLOCK_ACCESS_FS_MAKE_DIR) && \ + defined(LANDLOCK_ACCESS_FS_MAKE_REG) && \ + defined(LANDLOCK_ACCESS_FS_MAKE_SOCK) && \ + defined(LANDLOCK_ACCESS_FS_MAKE_FIFO) && \ + defined(LANDLOCK_ACCESS_FS_MAKE_BLOCK) && \ + defined(LANDLOCK_ACCESS_FS_MAKE_SYM) && \ + defined(LANDLOCK_ACCESS_FS_REFER) && \ + defined(LANDLOCK_ACCESS_FS_TRUNCATE) +#define DUNE_HAS_LANDLOCK 1 +#else +#define DUNE_HAS_LANDLOCK 0 +#endif + +#if DUNE_HAS_LANDLOCK +static uint64_t landlock_file_write_access_rights(int abi) { + uint64_t access = LANDLOCK_ACCESS_FS_WRITE_FILE; + if (abi >= 3) { + access |= LANDLOCK_ACCESS_FS_TRUNCATE; + } + return access; +} + +static uint64_t landlock_write_access_rights(int abi) { + uint64_t access = landlock_file_write_access_rights(abi) | + LANDLOCK_ACCESS_FS_REMOVE_DIR | + LANDLOCK_ACCESS_FS_REMOVE_FILE | + LANDLOCK_ACCESS_FS_MAKE_CHAR | + LANDLOCK_ACCESS_FS_MAKE_DIR | + LANDLOCK_ACCESS_FS_MAKE_REG | + LANDLOCK_ACCESS_FS_MAKE_SOCK | + LANDLOCK_ACCESS_FS_MAKE_FIFO | + LANDLOCK_ACCESS_FS_MAKE_BLOCK | + LANDLOCK_ACCESS_FS_MAKE_SYM; + if (abi >= 2) { + access |= LANDLOCK_ACCESS_FS_REFER; + } + return access; +} +#endif + +int dune_landlock_no_new_privs(void) { +#if DUNE_HAS_LANDLOCK + return prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); +#else + errno = ENOSYS; + return -1; +#endif +} + +int dune_landlock_restrict_self_fd(int ruleset_fd) { +#if DUNE_HAS_LANDLOCK + return syscall(SYS_landlock_restrict_self, ruleset_fd, 0); +#else + (void)ruleset_fd; + errno = ENOSYS; + return -1; +#endif +} + +CAMLprim value dune_landlock_abi_version(value unit) { + (void)unit; +#if DUNE_HAS_LANDLOCK + int abi = syscall(SYS_landlock_create_ruleset, NULL, 0, + LANDLOCK_CREATE_RULESET_VERSION); + if (abi < 0) { + switch (errno) { + case ENOSYS: + case EOPNOTSUPP: + case EINVAL: + return Val_int(0); + default: + uerror("landlock_create_ruleset", Nothing); + } + } + return Val_int(abi); +#else + return Val_int(0); +#endif +} + +CAMLprim value dune_landlock_write_access_rights(value v_abi) { + CAMLparam1(v_abi); +#if DUNE_HAS_LANDLOCK + CAMLreturn(caml_copy_int64(landlock_write_access_rights(Int_val(v_abi)))); +#else + CAMLreturn(caml_copy_int64(0)); +#endif +} + +CAMLprim value dune_landlock_file_write_access_rights(value v_abi) { + CAMLparam1(v_abi); +#if DUNE_HAS_LANDLOCK + CAMLreturn( + caml_copy_int64(landlock_file_write_access_rights(Int_val(v_abi)))); +#else + CAMLreturn(caml_copy_int64(0)); +#endif +} + +CAMLprim value dune_landlock_create_ruleset(value v_handled_access) { +#if DUNE_HAS_LANDLOCK + struct landlock_ruleset_attr ruleset_attr = { + .handled_access_fs = Int64_val(v_handled_access), + }; + int ruleset_fd = syscall(SYS_landlock_create_ruleset, &ruleset_attr, + sizeof(ruleset_attr), 0); + if (ruleset_fd < 0) { + uerror("landlock_create_ruleset", Nothing); + } + return Val_int(ruleset_fd); +#else + (void)v_handled_access; + errno = ENOSYS; + uerror("landlock_create_ruleset", Nothing); +#endif +} + +CAMLprim value dune_landlock_add_rule(value v_ruleset_fd, value v_path, + value v_allowed_access) { +#if DUNE_HAS_LANDLOCK + int path_fd = open(String_val(v_path), O_PATH | O_CLOEXEC); + if (path_fd < 0) { + uerror("open", v_path); + } + + struct landlock_path_beneath_attr path_beneath = { + .allowed_access = Int64_val(v_allowed_access), + .parent_fd = path_fd, + }; + int ret = syscall(SYS_landlock_add_rule, Int_val(v_ruleset_fd), + LANDLOCK_RULE_PATH_BENEATH, &path_beneath, 0); + int saved_errno = errno; + close(path_fd); + if (ret < 0) { + errno = saved_errno; + uerror("landlock_add_rule", v_path); + } + return Val_unit; +#else + (void)v_ruleset_fd; + (void)v_allowed_access; + errno = ENOSYS; + uerror("landlock_add_rule", v_path); +#endif +} + +CAMLprim value dune_landlock_restrict_self(value v_ruleset_fd) { +#if DUNE_HAS_LANDLOCK + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) { + uerror("prctl", Nothing); + } + if (syscall(SYS_landlock_restrict_self, Int_val(v_ruleset_fd), 0) != 0) { + uerror("landlock_restrict_self", Nothing); + } + return Val_unit; +#else + (void)v_ruleset_fd; + errno = ENOSYS; + uerror("landlock_restrict_self", Nothing); +#endif +} diff --git a/otherlibs/stdune/src/spawn.ml b/otherlibs/stdune/src/spawn.ml index ad91814949f..78f8813988d 100644 --- a/otherlibs/stdune/src/spawn.ml +++ b/otherlibs/stdune/src/spawn.ml @@ -101,7 +101,7 @@ external spawn_unix -> stderr:Unix.file_descr -> use_vfork:bool -> setpgid:int option - -> sigprocmask:(Unix.sigprocmask_command * int list) option + -> child_setup:(Unix.sigprocmask_command * int list) option * Unix.file_descr option -> int = "dune_spawn_unix_byte" "dune_spawn_unix" @@ -116,9 +116,20 @@ let spawn_unix ~use_vfork ~setpgid ~sigprocmask + ~landlock_ruleset = let setpgid = Option.map ~f:Pgid.to_int setpgid in - spawn_unix ~env ~cwd ~prog ~argv ~stdin ~stdout ~stderr ~use_vfork ~setpgid ~sigprocmask + spawn_unix + ~env + ~cwd + ~prog + ~argv + ~stdin + ~stdout + ~stderr + ~use_vfork + ~setpgid + ~child_setup:(sigprocmask, landlock_ruleset) |> Pid.of_int_exn ;; @@ -150,7 +161,10 @@ let spawn_windows ~use_vfork:_ ~setpgid:_ ~sigprocmask:_ + ~landlock_ruleset = + Option.iter landlock_ruleset ~f:(fun _ -> + invalid_arg "Spawn.spawn: Landlock is not supported on Windows"); let cwd = match (cwd : Working_dir.t) with | Path p -> Some p @@ -183,6 +197,7 @@ let spawn ?(stdin = Unix.stdin) ?(stdout = Unix.stdout) ?(stderr = Unix.stderr) + ?landlock ?(unix_backend = Unix_backend.default) ?setpgid ?sigprocmask @@ -199,7 +214,27 @@ let spawn | Vfork -> true | Fork -> false in - backend ~env ~cwd ~prog ~argv ~stdin ~stdout ~stderr ~use_vfork ~setpgid ~sigprocmask + let spawn landlock_ruleset = + backend + ~env + ~cwd + ~prog + ~argv + ~stdin + ~stdout + ~stderr + ~use_vfork + ~setpgid + ~sigprocmask + ~landlock_ruleset + in + match landlock with + | None -> spawn None + | Some policy -> + let ruleset = Landlock.Ruleset.create policy in + Exn.protect + ~f:(fun () -> spawn (Some (Landlock.Ruleset.file_descr ruleset))) + ~finally:(fun () -> Landlock.Ruleset.close ruleset) ;; external safe_pipe : unit -> Unix.file_descr * Unix.file_descr = "dune_spawn_pipe" diff --git a/otherlibs/stdune/src/spawn.mli b/otherlibs/stdune/src/spawn.mli index e736080c33e..f8f7bb2132b 100644 --- a/otherlibs/stdune/src/spawn.mli +++ b/otherlibs/stdune/src/spawn.mli @@ -104,6 +104,12 @@ end already blocked, or to block a signal that cannot be blocked (e.g., SIGSTOP, SIGKILL) are allowed and will be silently ignored. + {b Landlock} + + On Linux, [landlock] restricts the child with the supplied filesystem policy + immediately before executing [prog]. Only the spawned child is restricted; + the calling process and any other children are unaffected. + {b Implementation} [unix_backend] describes what backend to use on Unix. If set to [Default], @@ -117,6 +123,7 @@ val spawn -> ?stdin:Unix.file_descr -> ?stdout:Unix.file_descr -> ?stderr:Unix.file_descr + -> ?landlock:Landlock.Policy.t -> ?unix_backend:Unix_backend.t (* default: [Unix_backend.default] *) -> ?setpgid:Pgid.t -> ?sigprocmask:Unix.sigprocmask_command * int list diff --git a/otherlibs/stdune/src/spawn_stubs.c b/otherlibs/stdune/src/spawn_stubs.c index 4f76de46105..63b6b287265 100644 --- a/otherlibs/stdune/src/spawn_stubs.c +++ b/otherlibs/stdune/src/spawn_stubs.c @@ -26,6 +26,9 @@ #include +int dune_landlock_no_new_privs(void); +int dune_landlock_restrict_self_fd(int ruleset_fd); + #if defined(__APPLE__) # if defined(__MAC_OS_X_VERSION_MAX_ALLOWED) @@ -291,6 +294,7 @@ struct spawn_info { int set_pgid; pid_t pgid; sigset_t child_sigmask; + int landlock_ruleset_fd; }; static void subprocess(int failure_fd, struct spawn_info *info) @@ -345,6 +349,14 @@ static void subprocess(int failure_fd, struct spawn_info *info) pthread_sigmask(SIG_SETMASK, &info->child_sigmask, NULL); + if (info->landlock_ruleset_fd >= 0) { + if (dune_landlock_no_new_privs() != 0) + subprocess_failure(failure_fd, "prctl", NOTHING); + if (dune_landlock_restrict_self_fd(info->landlock_ruleset_fd) != 0) + subprocess_failure(failure_fd, "landlock_restrict_self", NOTHING); + close(info->landlock_ruleset_fd); + } + execve(info->prog, info->argv, info->env); subprocess_failure(failure_fd, "execve", PROG); } @@ -462,9 +474,11 @@ static void init_spawn_info(struct spawn_info *info, value v_stdout, value v_stderr, value v_setpgid, - value v_sigprocmask) + value v_child_setup) { extern char ** environ; + value v_sigprocmask = Field(v_child_setup, 0); + value v_landlock_ruleset = Field(v_child_setup, 1); info->std_fds[0] = Int_val(v_stdin); info->std_fds[1] = Int_val(v_stdout); @@ -501,6 +515,8 @@ static void init_spawn_info(struct spawn_info *info, info->pgid = Is_block(v_setpgid) ? Long_val(Field(v_setpgid, 0)) : 0; + info->landlock_ruleset_fd = + Is_block(v_landlock_ruleset) ? Int_val(Field(v_landlock_ruleset, 0)) : -1; if (v_sigprocmask == Val_long(0)) { sigemptyset(&info->child_sigmask); @@ -555,7 +571,7 @@ CAMLprim value dune_spawn_unix(value v_env, value v_stderr, value v_use_vfork, value v_setpgid, - value v_sigprocmask) + value v_child_setup) { CAMLparam4(v_env, v_cwd, v_prog, v_argv); CAMLlocal1(e_arg); @@ -581,7 +597,13 @@ CAMLprim value dune_spawn_unix(value v_env, struct spawn_info info; init_spawn_info(&info, v_env, v_cwd, v_prog, v_argv, - v_stdin, v_stdout, v_stderr, v_setpgid, v_sigprocmask); + v_stdin, v_stdout, v_stderr, v_setpgid, v_child_setup); + + if (info.landlock_ruleset_fd >= 0) { + e_error = ENOSYS; + e_function = "landlock_restrict_self"; + goto cleanup; + } short attr_flags = POSIX_SPAWN_SETSIGMASK; if (info.set_pgid) attr_flags |= POSIX_SPAWN_SETPGROUP; @@ -684,7 +706,7 @@ CAMLprim value dune_spawn_unix(value v_env, value v_stderr, value v_use_vfork, value v_setpgid, - value v_sigprocmask) + value v_child_setup) { CAMLparam4(v_env, v_cwd, v_prog, v_argv); pid_t ret; @@ -699,7 +721,7 @@ CAMLprim value dune_spawn_unix(value v_env, int status; init_spawn_info(&info, v_env, v_cwd, v_prog, v_argv, - v_stdin, v_stdout, v_stderr, v_setpgid, v_sigprocmask); + v_stdin, v_stdout, v_stderr, v_setpgid, v_child_setup); caml_enter_blocking_section(); enter_safe_pipe_section(); @@ -823,7 +845,7 @@ CAMLprim value dune_spawn_unix(value v_env, value v_stderr, value v_use_vfork, value v_setpgid, - value v_sigprocmask) + value v_child_setup) { (void)v_env; (void)v_cwd; @@ -834,7 +856,7 @@ CAMLprim value dune_spawn_unix(value v_env, (void)v_stderr; (void)v_use_vfork; (void)v_setpgid; - (void)v_sigprocmask; + (void)v_child_setup; unix_error(ENOSYS, "spawn_unix", Nothing); } diff --git a/otherlibs/stdune/src/stdune.ml b/otherlibs/stdune/src/stdune.ml index bc6cc26c0c0..5b6bfe3bdaa 100644 --- a/otherlibs/stdune/src/stdune.ml +++ b/otherlibs/stdune/src/stdune.ml @@ -120,6 +120,7 @@ module Unix_error = Unix_error module File_kind = File_kind module Alias_name = Alias_name module Action_runner_name = Action_runner_name +module Landlock = Landlock module Spawn = Spawn module type Per_item = Per_item_intf.S diff --git a/otherlibs/stdune/test/spawn/dune b/otherlibs/stdune/test/spawn/dune index 81994e179b0..656c7214cf4 100644 --- a/otherlibs/stdune/test/spawn/dune +++ b/otherlibs/stdune/test/spawn/dune @@ -1,5 +1,6 @@ (library (name stdune_spawn_tests) + (modules :standard \ landlock_tests) (inline_tests (deps exe/hello.exe @@ -18,3 +19,25 @@ ppx_inline_test.config) (preprocess (pps ppx_expect))) + +(library + (name stdune_spawn_landlock_tests) + (modules landlock_tests) + ;; Landlock requires ABI version 3, which this Linux-only enabled_if cannot + ;; detect. The test asserts availability at startup. + (enabled_if + (= %{system} linux)) + (inline_tests + (deps + (sandbox always))) + (libraries + stdune + unix + ;; This is because of the (implicit_transitive_deps false) + ;; in dune-project + ppx_expect.config + ppx_expect.config_types + base + ppx_inline_test.config) + (preprocess + (pps ppx_expect))) diff --git a/otherlibs/stdune/test/spawn/landlock_tests.ml b/otherlibs/stdune/test/spawn/landlock_tests.ml new file mode 100644 index 00000000000..dead6ac1f7a --- /dev/null +++ b/otherlibs/stdune/test/spawn/landlock_tests.ml @@ -0,0 +1,109 @@ +open Stdune + +let () = assert (Landlock.available ()) +let external_path path = Path.as_external path |> Option.value_exn +let exists path = Path.stat path |> Result.is_ok + +let spawn ?landlock root script = + let pid = + Spawn.spawn + ?landlock + ~cwd:(Spawn.Working_dir.Path (Path.to_string root)) + ~prog:"/bin/sh" + ~argv:[ "sh"; "-c"; "exec 2>/dev/null; " ^ script ] + () + in + let { Proc.Process_info.status; _ } = Proc.wait (Pid pid) [] |> Option.value_exn in + match status with + | WEXITED 0 -> () + | WEXITED code -> Printf.ksprintf failwith "child exited with code %d" code + | WSIGNALED signal -> Printf.ksprintf failwith "child got signal %d" signal + | WSTOPPED signal -> Printf.ksprintf failwith "child stopped with signal %d" signal +;; + +let%expect_test "landlock policy confines only the child" = + let root = Temp.create Dir ~prefix:"landlock" ~suffix:"test" in + let denied_dir = Path.relative root "denied" in + let writable_dir = Path.relative root "writable" in + let existing_file = Path.relative denied_dir "existing" in + let denied_new_file = Path.relative denied_dir "new" in + let outside_file = Path.relative writable_dir "child" in + let parent_file = Path.relative denied_dir "parent" in + Path.mkdir_p denied_dir; + Path.mkdir_p writable_dir; + Io.write_file existing_file "original\n"; + let policy = + Landlock.Policy.create ~deny_write:[ external_path denied_dir ] ~allow_write:[] + in + spawn + ~landlock:policy + root + "echo child > writable/child; if echo child > denied/new; then exit 10; fi; if echo \ + changed > denied/existing; then exit 11; fi"; + Io.write_file parent_file "parent\n"; + Printf.printf + "outside: %sdenied new exists: %b\nexisting: %sparent: %s" + (Io.read_file outside_file) + (exists denied_new_file) + (Io.read_file existing_file) + (Io.read_file parent_file); + [%expect + {| + outside: child + denied new exists: false + existing: original + parent: parent + |}] +;; + +let%expect_test "landlock policy allows explicit write holes" = + let root = Temp.create Dir ~prefix:"landlock" ~suffix:"test" in + let denied_dir = Path.relative root "denied" in + let allowed_dir = Path.relative denied_dir "allowed" in + let allowed_file = Path.relative allowed_dir "child" in + let denied_file = Path.relative denied_dir "blocked" in + Path.mkdir_p allowed_dir; + let policy = + Landlock.Policy.create + ~deny_write:[ external_path denied_dir ] + ~allow_write:[ external_path allowed_dir ] + in + spawn + ~landlock:policy + root + "echo child > denied/allowed/child; if echo child > denied/blocked; then exit 10; fi"; + Printf.printf + "allowed: %sdenied exists: %b\n" + (Io.read_file allowed_file) + (exists denied_file); + [%expect + {| + allowed: child + denied exists: false + |}] +;; + +let%expect_test "restriction does not leak into later spawns" = + let root = Temp.create Dir ~prefix:"landlock" ~suffix:"test" in + let denied_dir = Path.relative root "denied" in + let restricted_file = Path.relative denied_dir "restricted" in + let later_file = Path.relative denied_dir "later" in + Path.mkdir_p denied_dir; + let policy = + Landlock.Policy.create ~deny_write:[ external_path denied_dir ] ~allow_write:[] + in + (* The restricted child cannot write into the denied directory. *) + spawn ~landlock:policy root "if echo x > denied/restricted; then exit 10; fi"; + (* A child spawned afterwards without a policy is unaffected: the parent never + restricted itself, so the restriction is not inherited. *) + spawn root "echo later > denied/later"; + Printf.printf + "restricted exists: %b\nlater: %s" + (exists restricted_file) + (Io.read_file later_file); + [%expect + {| + restricted exists: false + later: later + |}] +;; diff --git a/test/blackbox-tests/test-cases/sandbox-actions/basic.t b/test/blackbox-tests/test-cases/sandbox-actions/basic.t index 7f6a7310e47..a52217a8e43 100644 --- a/test/blackbox-tests/test-cases/sandbox-actions/basic.t +++ b/test/blackbox-tests/test-cases/sandbox-actions/basic.t @@ -25,8 +25,12 @@ processes. $ dune build --sandbox-actions pure probe $ dune trace cat | jq -s 'include "dune"; writeFileCountBySuffix("/pure")' 0 - $ cmp -s host-ns _build/default/probe && echo same || echo different - different + $ if dune internal with-landlock -- true >/dev/null 2>&1; then + > cmp -s host-ns _build/default/probe && echo expected || echo unexpected + > else + > cmp -s host-ns _build/default/probe && echo unexpected || echo expected + > fi + expected $ cat _build/default/pure pure diff --git a/test/blackbox-tests/test-cases/sandbox-actions/dune b/test/blackbox-tests/test-cases/sandbox-actions/dune index aad0391ac21..2dc822ea4cb 100644 --- a/test/blackbox-tests/test-cases/sandbox-actions/dune +++ b/test/blackbox-tests/test-cases/sandbox-actions/dune @@ -1,7 +1,12 @@ (cram - (applies_to :whole_subtree) + (applies_to basic shared-cache with-bwrap) (alias runtest-bwrap) (runtest_alias (<> %{env:CI=false} true)) (deps %{bin:bwrap}) (enabled_if %{bin-available:bwrap})) + +(cram + (applies_to with-landlock) + (enabled_if + (= %{system} linux))) diff --git a/test/blackbox-tests/test-cases/sandbox-actions/shared-cache.t b/test/blackbox-tests/test-cases/sandbox-actions/shared-cache.t index 53393fed6b2..eb4bba60eeb 100644 --- a/test/blackbox-tests/test-cases/sandbox-actions/shared-cache.t +++ b/test/blackbox-tests/test-cases/sandbox-actions/shared-cache.t @@ -5,13 +5,20 @@ shared cache. $ export DUNE_CACHE_ROOT=$PWD/cache-root $ echo "$DUNE_CACHE_ROOT" > cache-root-name $ mkdir -p "$DUNE_CACHE_ROOT/db" + $ cat > try-cache-write.sh <<'EOF' + > if touch "$DUNE_CACHE_ROOT/db/runner-marker" 2>/dev/null; then + > echo wrote + > else + > echo blocked + > fi + > EOF $ cat > dune <<'EOF' > (rule > (target result) - > (deps cache-root-name) + > (deps cache-root-name try-cache-write.sh) > (action - > (bash - > "if touch \"$DUNE_CACHE_ROOT/db/runner-marker\" 2>/dev/null; then echo wrote > %{target}; else echo blocked > %{target}; fi"))) + > (with-stdout-to %{target} + > (run sh %{dep:try-cache-write.sh})))) > EOF Normal actions can still write there. @@ -31,6 +38,51 @@ Sandboxed actions are blocked from writing there. $ test -e "$DUNE_CACHE_ROOT/db/runner-marker" && echo present || echo missing missing +The Landlock backend enforces the same restriction when it is available. + + $ rm -f "$DUNE_CACHE_ROOT/db/runner-marker" + $ if dune internal with-landlock -- true >/dev/null 2>&1; then + > dune build --sandbox-actions --sandbox-actions-backend=landlock result + > cat _build/default/result + > test -e "$DUNE_CACHE_ROOT/db/runner-marker" && echo present || echo missing + > else + > echo blocked + > echo missing + > fi + blocked + missing + +When Landlock is available, the automatic backend does not require a working +bubblewrap binary. + + $ rm -f "$DUNE_CACHE_ROOT/db/runner-marker" + $ if dune internal with-landlock -- true >/dev/null 2>&1; then + > mkdir -p fake-bin + > cat > fake-bin/bwrap <<'EOF' + > #!/bin/sh + > echo broken bwrap >&2 + > exit 1 + > EOF + > chmod +x fake-bin/bwrap + > PATH=$PWD/fake-bin:$PATH dune build --sandbox-actions result + > cat _build/default/result + > test -e "$DUNE_CACHE_ROOT/db/runner-marker" && echo present || echo missing + > else + > echo blocked + > echo missing + > fi + blocked + missing + +The bubblewrap backend is still available explicitly. + + $ rm -f "$DUNE_CACHE_ROOT/db/runner-marker" + $ dune build --sandbox-actions --sandbox-actions-backend=bwrap result + $ cat _build/default/result + blocked + $ test -e "$DUNE_CACHE_ROOT/db/runner-marker" && echo present || echo missing + missing + If the shared cache path does not exist yet, dune creates it and still protects it from the worker. diff --git a/test/blackbox-tests/test-cases/sandbox-actions/with-bwrap.t b/test/blackbox-tests/test-cases/sandbox-actions/with-bwrap.t index 38f9c595c71..0c57aa62972 100644 --- a/test/blackbox-tests/test-cases/sandbox-actions/with-bwrap.t +++ b/test/blackbox-tests/test-cases/sandbox-actions/with-bwrap.t @@ -5,3 +5,23 @@ wrapped $ cmp -s host-ns wrapped-ns && echo same || echo different different + +It also applies Dune's shared-cache policy. + + $ export DUNE_CACHE_ROOT=$PWD/cache-root + $ mkdir -p "$DUNE_CACHE_ROOT/db" writable + $ echo cache > "$DUNE_CACHE_ROOT/db/item" + $ cat > check-cache-policy.sh <<'EOF' + > try_touch () { + > if touch "$1" 2>/dev/null; then echo "$2"; else echo "$3"; fi + > } + > cat "$DUNE_CACHE_ROOT/db/item" + > try_touch "$DUNE_CACHE_ROOT/db/new" cache-wrote cache-blocked + > try_touch writable/outside outside-wrote outside-blocked + > EOF + $ dune internal with-bwrap -- sh check-cache-policy.sh + cache + cache-blocked + outside-wrote + $ test -e "$DUNE_CACHE_ROOT/db/new" && echo present || echo missing + missing diff --git a/test/blackbox-tests/test-cases/sandbox-actions/with-landlock.t b/test/blackbox-tests/test-cases/sandbox-actions/with-landlock.t new file mode 100644 index 00000000000..00a65303a6a --- /dev/null +++ b/test/blackbox-tests/test-cases/sandbox-actions/with-landlock.t @@ -0,0 +1,25 @@ +`dune internal with-landlock` runs commands with the shared cache read-only. + + $ export DUNE_CACHE_ROOT=$PWD/cache-root + $ mkdir -p "$DUNE_CACHE_ROOT/db" writable + $ echo cache > "$DUNE_CACHE_ROOT/db/item" + $ cat > check-cache-policy.sh <<'EOF' + > try_touch () { + > if touch "$1" 2>/dev/null; then echo "$2"; else echo "$3"; fi + > } + > cat "$DUNE_CACHE_ROOT/db/item" + > try_touch "$DUNE_CACHE_ROOT/db/new" cache-wrote cache-blocked + > try_touch writable/outside outside-wrote outside-blocked + > EOF + $ if dune internal with-landlock -- true >/dev/null 2>&1; then + > dune internal with-landlock -- sh check-cache-policy.sh + > else + > echo cache + > echo cache-blocked + > echo outside-wrote + > fi + cache + cache-blocked + outside-wrote + $ test -e "$DUNE_CACHE_ROOT/db/new" && echo present || echo missing + missing