diff --git a/cmd/nerdctl/container/container_run_security_linux_test.go b/cmd/nerdctl/container/container_run_security_linux_test.go index 6a4cc35bb4b..ce845e62ab9 100644 --- a/cmd/nerdctl/container/container_run_security_linux_test.go +++ b/cmd/nerdctl/container/container_run_security_linux_test.go @@ -26,9 +26,15 @@ import ( "gotest.tools/v3/assert" + "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/require" + "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" + "github.com/containerd/nerdctl/v2/pkg/apparmorutil" "github.com/containerd/nerdctl/v2/pkg/rootlessutil" "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) func getCapEff(base *testutil.Base, args ...string) uint64 { @@ -186,6 +192,102 @@ func TestRunApparmor(t *testing.T) { base.Cmd("run", "--rm", "--privileged", testutil.AlpineImage, "cat", attrCurrentPath).AssertOutContains("unconfined") } +func TestRunSelinuxWithSecurityOpt(t *testing.T) { + testCase := nerdtest.Setup() + testCase.Require = require.Not(nerdtest.NoSelinux) + testContainer := testutil.Identifier(t) + + testCase.SubTests = []*test.Case{ + { + Description: "test run with selinux-enabled", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("--selinux-enabled", "run", "-d", "--security-opt", "label=type:container_t", "--name", testContainer, "sleep", "infinity") + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", testContainer) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.All( + func(stdout string, t tig.T) { + inspectOut := helpers.Capture("container", "inspect", "--format", "{{.State.Pid}}", testContainer) + pid := strings.TrimSpace(inspectOut) + fileName := fmt.Sprintf("/proc/%s/attr/current", pid) + data, err := os.ReadFile(fileName) + assert.NilError(t, err) + assert.Equal(t, strings.Contains(string(data), "container_t"), true) + }, + ), + } + }, + }, + } +} +func TestRunSelinux(t *testing.T) { + testCase := nerdtest.Setup() + testCase.Require = require.Not(nerdtest.NoSelinux) + testContainer := testutil.Identifier(t) + + testCase.SubTests = []*test.Case{ + { + Description: "test run with selinux-enabled", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("--selinux-enabled", "run", "-d", "--name", testContainer, "sleep", "infinity") + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", testContainer) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.All( + func(stdout string, t tig.T) { + inspectOut := helpers.Capture("container", "inspect", "--format", "{{.State.Pid}}", testContainer) + pid := strings.TrimSpace(inspectOut) + fileName := fmt.Sprintf("/proc/%s/attr/current", pid) + data, err := os.ReadFile(fileName) + assert.NilError(t, err) + assert.Equal(t, strings.Contains(string(data), "container_t"), true) + }, + ), + } + }, + }, + } +} + +func TestRunSelinuxWithVolumeLabel(t *testing.T) { + testCase := nerdtest.Setup() + testCase.Require = require.Not(nerdtest.NoSelinux) + testContainer := testutil.Identifier(t) + + testCase.SubTests = []*test.Case{ + { + Description: "test run with selinux-enabled", + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("--selinux-enabled", "run", "-d", "-v", fmt.Sprintf("/%s:/%s:Z", testContainer, testContainer), "--name", testContainer, "sleep", "infinity") + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", testContainer) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.All( + func(stdout string, t tig.T) { + cmd := exec.Command("ls", "-Z", fmt.Sprintf("/%s", testContainer)) + lsStdout, err := cmd.CombinedOutput() + assert.NilError(t, err) + assert.Equal(t, strings.Contains(string(lsStdout), "container_t"), true) + }, + ), + } + }, + }, + } +} + // TestRunSeccompCapSysPtrace tests https://github.com/containerd/nerdctl/issues/976 func TestRunSeccompCapSysPtrace(t *testing.T) { base := testutil.NewBase(t) diff --git a/cmd/nerdctl/helpers/flagutil.go b/cmd/nerdctl/helpers/flagutil.go index 22fc1acb1bf..1ebed1f35d1 100644 --- a/cmd/nerdctl/helpers/flagutil.go +++ b/cmd/nerdctl/helpers/flagutil.go @@ -154,6 +154,10 @@ func ProcessRootCmdFlags(cmd *cobra.Command) (types.GlobalCommandOptions, error) return types.GlobalCommandOptions{}, err } + selinuxEnabled, err := cmd.Flags().GetBool("selinux-enabled") + if err != nil { + return types.GlobalCommandOptions{}, err + } // Point to dataRoot for filesystem-helpers implementing rollback / backups. err = fs.InitFS(dataRoot) if err != nil { @@ -180,6 +184,7 @@ func ProcessRootCmdFlags(cmd *cobra.Command) (types.GlobalCommandOptions, error) DNS: dns, DNSOpts: dnsOpts, DNSSearch: dnsSearch, + SelinuxEnabled: selinuxEnabled, }, nil } diff --git a/cmd/nerdctl/main.go b/cmd/nerdctl/main.go index 51dfb26736e..cd2fb460408 100644 --- a/cmd/nerdctl/main.go +++ b/cmd/nerdctl/main.go @@ -188,6 +188,7 @@ func initRootCmdFlags(rootCmd *cobra.Command, tomlPath string) (*pflag.FlagSet, helpers.AddPersistentStringFlag(rootCmd, "host-gateway-ip", nil, nil, nil, aliasToBeInherited, cfg.HostGatewayIP, "NERDCTL_HOST_GATEWAY_IP", "IP address that the special 'host-gateway' string in --add-host resolves to. Defaults to the IP address of the host. It has no effect without setting --add-host") helpers.AddPersistentStringFlag(rootCmd, "bridge-ip", nil, nil, nil, aliasToBeInherited, cfg.BridgeIP, "NERDCTL_BRIDGE_IP", "IP address for the default nerdctl bridge network") rootCmd.PersistentFlags().Bool("kube-hide-dupe", cfg.KubeHideDupe, "Deduplicate images for Kubernetes with namespace k8s.io") + rootCmd.PersistentFlags().Bool("selinux-enabled", cfg.SelinuxEnabled, "Enable selinux support") rootCmd.PersistentFlags().StringSlice("cdi-spec-dirs", cfg.CDISpecDirs, "The directories to search for CDI spec files. Defaults to /etc/cdi,/var/run/cdi") rootCmd.PersistentFlags().String("userns-remap", cfg.UsernsRemap, "Support idmapping for creating and running containers. This options is only supported on linux. If `host` is passed, no idmapping is done. if a user name is passed, it does idmapping based on the uidmap and gidmap ranges specified in /etc/subuid and /etc/subgid respectively") helpers.HiddenPersistentStringArrayFlag(rootCmd, "global-dns", cfg.DNS, "Global DNS servers for containers") diff --git a/docs/command-reference.md b/docs/command-reference.md index 6786c757f0c..249f7bc8152 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -254,6 +254,7 @@ Security flags: - :whale: `--security-opt seccomp=`: specify custom seccomp profile - :whale: `--security-opt apparmor=`: specify custom AppArmor profile + :whale: `--security-opt label=`: specify custom selinux label - :whale: `--security-opt no-new-privileges`: disallow privilege escalation, e.g., setuid and file capabilities - :whale: `--security-opt systempaths=unconfined`: Turn off confinement for system paths (masked paths, read-only paths) for the container - :whale: `--security-opt writable-cgroups`: making the cgroups writeable @@ -1959,6 +1960,7 @@ Flags: - :nerd_face: `--host-gateway-ip`: IP address that the special 'host-gateway' string in --add-host resolves to. It has no effect without setting --add-host - Default: the IP address of the host - :nerd_face: `--userns-remap=:`: Support idmapping of containers. This options is only supported on rootful linux for container create and run if a user name and optionally group name is passed, it does idmapping based on the uidmap and gidmap ranges specified in /etc/subuid and /etc/subgid respectively. Note: `--userns-remap` is not supported for building containers. Nerdctl Build doesn't support userns-remap feature. (format: [:]) +- :nerd_face: `--selinux-enabled`: Enable selinux support The global flags can be also specified in `/etc/nerdctl/nerdctl.toml` (rootful) and `~/.config/nerdctl/nerdctl.toml` (rootless). See [`./config.md`](./config.md). diff --git a/docs/config.md b/docs/config.md index 4ed70965948..f2535309e8d 100644 --- a/docs/config.md +++ b/docs/config.md @@ -30,6 +30,7 @@ userns_remap = "" dns = ["8.8.8.8", "1.1.1.1"] dns_opts = ["ndots:1", "timeout:2"] dns_search = ["example.com", "example.org"] +selinux_enabled= true ``` ## Properties diff --git a/go.mod b/go.mod index 3f3c7d2a838..0e49c61077c 100644 --- a/go.mod +++ b/go.mod @@ -53,6 +53,7 @@ require ( github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.1 github.com/opencontainers/runtime-spec v1.2.1 + github.com/opencontainers/selinux v1.13.0 github.com/pelletier/go-toml/v2 v2.2.4 github.com/rootless-containers/bypass4netns v0.4.2 //gomodjail:unconfined github.com/rootless-containers/rootlesskit/v2 v2.3.5 //gomodjail:unconfined @@ -113,7 +114,6 @@ require ( github.com/multiformats/go-multihash v0.2.3 // indirect github.com/multiformats/go-varint v0.1.0 // indirect github.com/opencontainers/runtime-tools v0.9.1-0.20250523060157-0ea5ed0382a2 // indirect - github.com/opencontainers/selinux v1.13.0 // indirect github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pkg/errors v0.9.1 // indirect diff --git a/pkg/cmd/container/run_linux.go b/pkg/cmd/container/run_linux.go index 3280d3e532d..e080cb8779a 100644 --- a/pkg/cmd/container/run_linux.go +++ b/pkg/cmd/container/run_linux.go @@ -72,7 +72,7 @@ func setPlatformOptions(ctx context.Context, client *containerd.Client, id, uts } opts = append(opts, capOpts...) securityOptsMaps := strutil.ConvertKVStringsToMap(strutil.DedupeStrSlice(options.SecurityOpt)) - secOpts, err := generateSecurityOpts(options.Privileged, securityOptsMaps) + secOpts, err := generateSecurityOpts(options.Privileged, options.GOptions.SelinuxEnabled, securityOptsMaps) if err != nil { return nil, err } diff --git a/pkg/cmd/container/run_security_linux.go b/pkg/cmd/container/run_security_linux.go index dbd76234c1b..c871f37fe9b 100644 --- a/pkg/cmd/container/run_security_linux.go +++ b/pkg/cmd/container/run_security_linux.go @@ -17,14 +17,19 @@ package container import ( + "context" "errors" "fmt" "strconv" "strings" "sync" + "github.com/opencontainers/runtime-spec/specs-go" + "github.com/opencontainers/selinux/go-selinux/label" + "github.com/containerd/containerd/v2/contrib/apparmor" "github.com/containerd/containerd/v2/contrib/seccomp" + "github.com/containerd/containerd/v2/core/containers" "github.com/containerd/containerd/v2/pkg/cap" "github.com/containerd/containerd/v2/pkg/oci" "github.com/containerd/log" @@ -51,10 +56,10 @@ const ( systemPathsUnconfined = "unconfined" ) -func generateSecurityOpts(privileged bool, securityOptsMap map[string]string) ([]oci.SpecOpts, error) { +func generateSecurityOpts(privileged bool, selinuxEnabled bool, securityOptsMap map[string]string) ([]oci.SpecOpts, error) { for k := range securityOptsMap { switch k { - case "seccomp", "apparmor", "no-new-privileges", "systempaths", "privileged-without-host-devices", "writable-cgroups": + case "seccomp", "apparmor", "no-new-privileges", "systempaths", "privileged-without-host-devices", "writable-cgroups", "label": default: log.L.Warnf("unknown security-opt: %q", k) } @@ -95,6 +100,23 @@ func generateSecurityOpts(privileged bool, securityOptsMap map[string]string) ([ opts = append(opts, apparmor.WithProfile(defaults.AppArmorProfileName)) } } + var labelOpts []string + if selinuxLabel, ok := securityOptsMap["label"]; ok { + labelOpts = append(labelOpts, selinuxLabel) + processLabel, mountLabel, err := label.InitLabels(labelOpts) + if err != nil { + return nil, err + } + opts = append(opts, WithSelinuxLabel(processLabel, mountLabel)) + } + // if selinux-enabled=true and security-opt selinux label is not set. + if selinuxEnabled && len(labelOpts) == 0 { + processLabel, mountLabel, err := label.InitLabels(labelOpts) + if err != nil { + return nil, err + } + opts = append(opts, WithSelinuxLabel(processLabel, mountLabel)) + } nnp, err := maputil.MapBoolValueAsOpt(securityOptsMap, "no-new-privileges") if err != nil { @@ -141,6 +163,21 @@ func generateSecurityOpts(privileged bool, securityOptsMap map[string]string) ([ return opts, nil } +// WithSelinuxLabels sets the mount and process labels +func WithSelinuxLabel(process, mount string) oci.SpecOpts { + return func(_ context.Context, _ oci.Client, _ *containers.Container, s *oci.Spec) error { + if s.Linux == nil { + s.Linux = &specs.Linux{} + } + if s.Process == nil { + s.Process = &specs.Process{} + } + s.Linux.MountLabel = mount + s.Process.SelinuxLabel = process + return nil + } +} + func canonicalizeCapName(s string) string { if s == "" { return "" diff --git a/pkg/config/config.go b/pkg/config/config.go index 5ecf41b9256..e967c91393a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -47,6 +47,7 @@ type Config struct { DNSOpts []string `toml:"dns_opts,omitempty"` DNSSearch []string `toml:"dns_search,omitempty"` DisableHCSystemd bool `toml:"disable_hc_systemd"` + SelinuxEnabled bool `toml:"selinux_enabled"` } // New creates a default Config object statically, @@ -63,6 +64,7 @@ func New() *Config { DataRoot: ncdefaults.DataRoot(), CgroupManager: ncdefaults.CgroupManager(), InsecureRegistry: false, + SelinuxEnabled: false, HostsDir: ncdefaults.HostsDirs(), Experimental: true, HostGatewayIP: ncdefaults.HostGatewayIP(), diff --git a/pkg/mountutil/mountutil_linux.go b/pkg/mountutil/mountutil_linux.go index a6a79d8e963..6cc13727077 100644 --- a/pkg/mountutil/mountutil_linux.go +++ b/pkg/mountutil/mountutil_linux.go @@ -28,6 +28,7 @@ import ( "github.com/docker/go-units" mobymount "github.com/moby/sys/mount" "github.com/opencontainers/runtime-spec/specs-go" + "github.com/opencontainers/selinux/go-selinux/label" "golang.org/x/sys/unix" "github.com/containerd/containerd/v2/core/containers" @@ -112,6 +113,7 @@ func parseVolumeOptionsWithMountInfo(vType, src, optsRaw string, getMountInfoFun propagationRawOpts []string bindOpts []string ) + var specOpts []oci.SpecOpts for _, opt := range strings.Split(optsRaw, ",") { switch opt { case "rw", "ro", "rro": @@ -121,6 +123,15 @@ func parseVolumeOptionsWithMountInfo(vType, src, optsRaw string, getMountInfoFun case "bind", "rbind": // bind means not recursively bind-mounted, rbind is the opposite bindOpts = append(bindOpts, opt) + case "Z", "z": + specOpts = append(specOpts, func(ctx context.Context, cli oci.Client, c *containers.Container, s *oci.Spec) error { + if s.Linux != nil && s.Linux.MountLabel != "" { + if err := label.Relabel(src, s.Linux.MountLabel, strings.Contains(opt, "z")); err != nil { + return err + } + } + return nil + }) case "": // NOP default: @@ -129,7 +140,6 @@ func parseVolumeOptionsWithMountInfo(vType, src, optsRaw string, getMountInfoFun } var opts []string - var specOpts []oci.SpecOpts if len(bindOpts) > 0 && vType != Bind { return nil, nil, fmt.Errorf("volume bind/rbind option is only supported for bind mount: %+v", bindOpts) diff --git a/pkg/testutil/nerdtest/requirements.go b/pkg/testutil/nerdtest/requirements.go index d4af8490339..ef4e73030f2 100644 --- a/pkg/testutil/nerdtest/requirements.go +++ b/pkg/testutil/nerdtest/requirements.go @@ -25,6 +25,7 @@ import ( "strings" "github.com/Masterminds/semver/v3" + "github.com/opencontainers/selinux/go-selinux" "gotest.tools/v3/assert" "github.com/containerd/containerd/v2/defaults" @@ -161,6 +162,19 @@ var Rootless = &test.Requirement{ }, } +// NoSexlinux marks a test as suitable only for the noselinux enable environment +var NoSelinux = &test.Requirement{ + Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) { + ret = !selinux.GetEnabled() + if ret { + mess = "selinux is disabled" + } else { + mess = "selinux is enabled" + } + return ret, mess + }, +} + // RootlessWithDetachNetNS marks a test as suitable only for rootless environment with detached netns support. var RootlessWithDetachNetNS = &test.Requirement{ Check: func(data test.Data, helpers test.Helpers) (ret bool, mess string) {