diff --git a/cli/command/service/opts.go b/cli/command/service/opts.go index 9298cfb13145..aa26dda9381e 100644 --- a/cli/command/service/opts.go +++ b/cli/command/service/opts.go @@ -5,7 +5,10 @@ package service import ( "context" + "encoding/json" "fmt" + "os" + "path/filepath" "sort" "strconv" "strings" @@ -531,6 +534,10 @@ type serviceOptions struct { ulimits opts.UlimitOpt oomScoreAdj int64 + seccomp string + appArmor string + noNewPrivileges bool + resources resourceOptions stopGrace opts.DurationOpt @@ -660,6 +667,101 @@ func (options *serviceOptions) makeEnv() ([]string, error) { return currentEnv, nil } +func (options *serviceOptions) ToPrivileges(flags *pflag.FlagSet) (*swarm.Privileges, error) { + // we're going to go through several possible uses of the Privileges + // struct, which may or may not be used. If some stage uses it (after the + // first), we'll check if it's nil and create it if it hasn't been created + // yet. + var privileges *swarm.Privileges + if options.credentialSpec.String() != "" && options.credentialSpec.Value() != nil { + privileges = &swarm.Privileges{ + CredentialSpec: options.credentialSpec.Value(), + } + } + + if flags.Changed(flagNoNewPrivileges) { + if privileges == nil { + privileges = &swarm.Privileges{} + } + privileges.NoNewPrivileges = options.noNewPrivileges + } + + if flags.Changed(flagAppArmor) { + if privileges == nil { + privileges = &swarm.Privileges{} + } + switch options.appArmor { + case "default": + privileges.AppArmor = &swarm.AppArmorOpts{ + Mode: swarm.AppArmorModeDefault, + } + case "disabled": + privileges.AppArmor = &swarm.AppArmorOpts{ + Mode: swarm.AppArmorModeDisabled, + } + default: + return nil, errors.Errorf( + "unknown AppArmor mode %q. Supported modes are %q and %q", + options.appArmor, + swarm.AppArmorModeDefault, + swarm.AppArmorModeDisabled, + ) + } + } + + if flags.Changed(flagSeccomp) { + if privileges == nil { + privileges = &swarm.Privileges{} + } + switch arg := options.seccomp; arg { + case "default": + privileges.Seccomp = &swarm.SeccompOpts{ + Mode: swarm.SeccompModeDefault, + } + case "unconfined": + privileges.Seccomp = &swarm.SeccompOpts{ + Mode: swarm.SeccompModeUnconfined, + } + default: + dir, _ := filepath.Split(arg) + // if the directory is empty, this isn't a file path. Even though + // the user may be referring to a file in the local directory, for + // disambiguation's sake, we require a custom profile file to be + // given as a path. + if dir == "" { + // check if the file exists locally + if _, err := os.Stat(arg); errors.Is(err, os.ErrNotExist) { + return nil, errors.Errorf("unknown seccomp mode %q.", arg) + } + return nil, errors.Errorf( + "unknown seccomp mode %q. (did you mean custom a seccomp profile \"./%s\"?)", + arg, arg, + ) + } + data, err := os.ReadFile(options.seccomp) + if err != nil { + // TODO(dperny): return this, or return "unrecognized option" or some such? + return nil, errors.Wrap(err, "unable to read seccomp custom profile file") + } + // we're doing the user a favor here by refusing to pass garbage if + // they give invalid json. + if !json.Valid(data) { + return nil, errors.Errorf( + "unable to read seccomp custom profile file %q: not valid json", + options.seccomp, + ) + } + + privileges.Seccomp = &swarm.SeccompOpts{ + Mode: swarm.SeccompModeCustom, + Profile: data, + } + } + } + + return privileges, nil +} + // ToService takes the set of flags passed to the command and converts them // into a service spec. // @@ -712,6 +814,11 @@ func (options *serviceOptions) ToService(ctx context.Context, apiClient client.N return service, err } + privileges, err := options.ToPrivileges(flags) + if err != nil { + return service, err + } + capAdd, capDrop := opts.EffectiveCapAddCapDrop(options.capAdd.GetAll(), options.capDrop.GetAll()) service = swarm.ServiceSpec{ @@ -730,6 +837,7 @@ func (options *serviceOptions) ToService(ctx context.Context, apiClient client.N Dir: options.workdir, User: options.user, Groups: options.groups.GetAll(), + Privileges: privileges, StopSignal: options.stopSignal, TTY: options.tty, ReadOnly: options.readOnly, @@ -766,12 +874,6 @@ func (options *serviceOptions) ToService(ctx context.Context, apiClient client.N EndpointSpec: options.endpoint.ToEndpointSpec(), } - if options.credentialSpec.String() != "" && options.credentialSpec.Value() != nil { - service.TaskTemplate.ContainerSpec.Privileges = &swarm.Privileges{ - CredentialSpec: options.credentialSpec.Value(), - } - } - return service, nil } @@ -886,6 +988,10 @@ func addServiceFlags(flags *pflag.FlagSet, options *serviceOptions, defaultFlagV flags.StringVar(&options.update.order, flagUpdateOrder, "", flagDesc(flagUpdateOrder, `Update order ("start-first", "stop-first")`)) flags.SetAnnotation(flagUpdateOrder, "version", []string{"1.29"}) + flags.StringVar(&options.seccomp, flagSeccomp, "", flagDesc(flagSeccomp, `Seccomp configuration ("default", "unconfined", or seccomp Json file name)`)) + flags.StringVar(&options.appArmor, flagAppArmor, "", flagDesc(flagAppArmor, `AppArmor mode ("default" or "disabled"`)) + flags.BoolVar(&options.noNewPrivileges, flagNoNewPrivileges, false, flagDesc(flagNoNewPrivileges, "Disable container processes from gaining new privileges")) + flags.Uint64Var(&options.rollback.parallelism, flagRollbackParallelism, defaultFlagValues.getUint64(flagRollbackParallelism), "Maximum number of tasks rolled back simultaneously (0 to roll back all at once)") flags.SetAnnotation(flagRollbackParallelism, "version", []string{"1.28"}) @@ -937,6 +1043,7 @@ func addServiceFlags(flags *pflag.FlagSet, options *serviceOptions, defaultFlagV } const ( + flagAppArmor = "apparmor" flagCredentialSpec = "credential-spec" //nolint:gosec // ignore G101: Potential hardcoded credentials flagPlacementPref = "placement-pref" flagPlacementPrefAdd = "placement-pref-add" @@ -1008,6 +1115,7 @@ const ( flagRollbackOrder = "rollback-order" flagRollbackParallelism = "rollback-parallelism" flagInit = "init" + flagSeccomp = "seccomp" flagSysCtl = "sysctl" flagSysCtlAdd = "sysctl-add" flagSysCtlRemove = "sysctl-rm" @@ -1023,6 +1131,7 @@ const ( flagUser = "user" flagWorkdir = "workdir" flagRegistryAuth = "with-registry-auth" + flagNoNewPrivileges = "no-new-privileges" flagNoResolveImage = "no-resolve-image" flagLogDriver = "log-driver" flagLogOpt = "log-opt" diff --git a/cli/command/service/opts_test.go b/cli/command/service/opts_test.go index 799d32cebd2d..a43968a005b3 100644 --- a/cli/command/service/opts_test.go +++ b/cli/command/service/opts_test.go @@ -3,6 +3,8 @@ package service import ( "context" "fmt" + "os" + "strings" "testing" "time" @@ -326,3 +328,142 @@ func TestToServiceSysCtls(t *testing.T) { assert.NilError(t, err) assert.Check(t, is.DeepEqual(service.TaskTemplate.ContainerSpec.Sysctls, expected)) } + +func TestToPrivilegesAppArmor(t *testing.T) { + for _, mode := range []string{"default", "disabled"} { + flags := newCreateCommand(nil).Flags() + flags.Set("apparmor", mode) + o := newServiceOptions() + o.appArmor = mode + privileges, err := o.ToPrivileges(flags) + assert.NilError(t, err) + enumMode := swarm.AppArmorMode(mode) + assert.Check(t, is.DeepEqual(privileges, &swarm.Privileges{ + AppArmor: &swarm.AppArmorOpts{ + Mode: enumMode, + }, + })) + } +} + +func TestToPrivilegesAppArmorInvalid(t *testing.T) { + flags := newCreateCommand(nil).Flags() + flags.Set("apparmor", "invalid") + o := newServiceOptions() + o.appArmor = "invalid" + + privileges, err := o.ToPrivileges(flags) + assert.ErrorContains(t, err, "AppArmor") + assert.Check(t, is.Nil(privileges)) +} + +func TestToPrivilegesSeccomp(t *testing.T) { + for _, mode := range []string{"default", "unconfined"} { + flags := newCreateCommand(nil).Flags() + flags.Set("seccomp", mode) + o := newServiceOptions() + o.seccomp = mode + + privileges, err := o.ToPrivileges(flags) + assert.NilError(t, err) + enumMode := swarm.SeccompMode(mode) + assert.Check(t, is.DeepEqual(privileges, &swarm.Privileges{ + Seccomp: &swarm.SeccompOpts{ + Mode: enumMode, + }, + })) + } +} + +const testJSON = `{ + "json": "you betcha" +} +` + +func TestToPrivilegesSeccompCustomProfile(t *testing.T) { + flags := newCreateCommand(nil).Flags() + flags.Set("seccomp", "testdata/test-seccomp-valid.json") + o := newServiceOptions() + o.seccomp = "testdata/test-seccomp-valid.json" + + privileges, err := o.ToPrivileges(flags) + assert.NilError(t, err) + assert.Check(t, is.DeepEqual(privileges, &swarm.Privileges{ + Seccomp: &swarm.SeccompOpts{ + Mode: swarm.SeccompModeCustom, + Profile: []byte(testJSON), + }, + })) +} + +func TestToPrivilegesSeccompInvalidJson(t *testing.T) { + flags := newCreateCommand(nil).Flags() + // why make an invalid json file when we have one lying right there? + flags.Set("seccomp", "testdata/service-context-write-raw.golden") + o := newServiceOptions() + o.seccomp = "testdata/service-context-write-raw.golden" + + privileges, err := o.ToPrivileges(flags) + assert.ErrorContains(t, err, "json") + assert.Check(t, is.Nil(privileges)) +} + +// TestToPrivilegesSeccompNotPath tests that if the user provides a valid +// filename but not as a path, we both fail the command (as the argument isn't +// a valid seccomp mode) and hint that the user should provide the path as a +// relative path. +func TestToPrivilegesSeccompNotPath(t *testing.T) { + // change the working directory in this test so that the file with no + // separators is a valid file. This will revert at the end of the test. + // Cannot use this with t.Parallel() tests. + // TODO(dperny): When we get to go 1.24, use t.Chdir instead. + oldwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir("testdata"); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.Chdir(oldwd); err != nil { + panic(err) + } + }) + flags := newCreateCommand(nil).Flags() + flags.Set("seccomp", "test-seccomp-valid.json") + o := newServiceOptions() + o.seccomp = "test-seccomp-valid.json" + + privileges, err := o.ToPrivileges(flags) + assert.ErrorContains(t, err, "unknown seccomp mode") + assert.ErrorContains(t, err, "did you mean") + t.Logf("%s", err) + assert.Check(t, is.Nil(privileges)) +} + +// TestToPrivilegesSeccompNotPathNotValid is like +// TestToPrivilegesSeccompNotPath except the argument isn't a valid file at +// all, so there's no hint. +func TestToPrivilegesSeccompNotPathNotValid(t *testing.T) { + flags := newCreateCommand(nil).Flags() + flags.Set("seccomp", "test-seccomp-valid.json") + o := newServiceOptions() + o.seccomp = "test-seccomp-valid.json" + + privileges, err := o.ToPrivileges(flags) + assert.ErrorContains(t, err, "unknown seccomp mode") + t.Logf("%s", err) + assert.Check(t, is.Nil(privileges)) + assert.Check(t, !strings.Contains(err.Error(), "did you mean")) +} + +func TestToPrivilegesNoNewPrivileges(t *testing.T) { + flags := newCreateCommand(nil).Flags() + flags.Set("no-new-privileges", "true") + o := newServiceOptions() + o.noNewPrivileges = true + + privileges, err := o.ToPrivileges(flags) + assert.NilError(t, err) + assert.Check(t, is.DeepEqual(privileges, &swarm.Privileges{NoNewPrivileges: true})) +} diff --git a/cli/command/service/testdata/test-seccomp-valid.json b/cli/command/service/testdata/test-seccomp-valid.json new file mode 100644 index 000000000000..521aa8eddb4f --- /dev/null +++ b/cli/command/service/testdata/test-seccomp-valid.json @@ -0,0 +1,3 @@ +{ + "json": "you betcha" +} diff --git a/cli/compose/convert/service.go b/cli/compose/convert/service.go index 44b6de1415c6..10751e6a77e2 100644 --- a/cli/compose/convert/service.go +++ b/cli/compose/convert/service.go @@ -2,6 +2,7 @@ package convert import ( "context" + "encoding/json" "os" "sort" "strings" @@ -101,6 +102,31 @@ func Service( privileges.CredentialSpec, err = convertCredentialSpec( namespace, service.CredentialSpec, configs, ) + switch appArmor := service.AppArmor; appArmor { + case string(swarm.AppArmorModeDefault): + privileges.AppArmor = &swarm.AppArmorOpts{Mode: swarm.AppArmorModeDefault} + case string(swarm.AppArmorModeDisabled): + privileges.AppArmor = &swarm.AppArmorOpts{Mode: swarm.AppArmorModeDisabled} + } + + switch seccomp := service.Seccomp; seccomp { + case string(swarm.SeccompModeDefault): + privileges.Seccomp = &swarm.SeccompOpts{Mode: swarm.SeccompModeDefault} + case string(swarm.SeccompModeUnconfined): + privileges.Seccomp = &swarm.SeccompOpts{Mode: swarm.SeccompModeUnconfined} + default: + // check if the value of seccompmode is valid json. if so, then use it + // as a seccomp profile. + if json.Valid([]byte(seccomp)) { + privileges.Seccomp = &swarm.SeccompOpts{ + Mode: swarm.SeccompModeCustom, + Profile: []byte(seccomp), + } + } + } + + privileges.NoNewPrivileges = service.NoNewPrivileges + if err != nil { return swarm.ServiceSpec{}, err } diff --git a/cli/compose/convert/service_test.go b/cli/compose/convert/service_test.go index a7c8bf28ca8a..2f65e0f60225 100644 --- a/cli/compose/convert/service_test.go +++ b/cli/compose/convert/service_test.go @@ -698,3 +698,46 @@ func TestConvertServiceCapAddAndCapDrop(t *testing.T) { }) } } + +func TestConvertPrivileges(t *testing.T) { + config := composetypes.ServiceConfig{ + AppArmor: "disabled", + Seccomp: "unconfined", + NoNewPrivileges: true, + } + + result, err := Service("1.42", Namespace{name: "foo"}, config, nil, nil, nil, nil) + assert.NilError(t, err) + assert.Check(t, result.TaskTemplate.ContainerSpec.Privileges != nil) + assert.Check(t, is.DeepEqual( + result.TaskTemplate.ContainerSpec.Privileges.AppArmor, + &swarm.AppArmorOpts{ + Mode: swarm.AppArmorModeDisabled, + }, + )) + assert.Check(t, is.DeepEqual( + result.TaskTemplate.ContainerSpec.Privileges.Seccomp, + &swarm.SeccompOpts{ + Mode: swarm.SeccompModeUnconfined, + }, + )) + + assert.Check(t, result.TaskTemplate.ContainerSpec.Privileges.NoNewPrivileges) +} + +func TestConvertCustomSeccomp(t *testing.T) { + config := composetypes.ServiceConfig{ + Seccomp: `{"foo": "bar"}`, + } + + result, err := Service("1.42", Namespace{name: "foo"}, config, nil, nil, nil, nil) + assert.NilError(t, err) + assert.Check(t, result.TaskTemplate.ContainerSpec.Privileges != nil) + assert.Check(t, is.DeepEqual( + result.TaskTemplate.ContainerSpec.Privileges.Seccomp, + &swarm.SeccompOpts{ + Mode: swarm.SeccompModeCustom, + Profile: []byte(`{"foo": "bar"}`), + }, + )) +} diff --git a/cli/compose/loader/full-example.yml b/cli/compose/loader/full-example.yml index 36ebf833e708..fda2a1c327cf 100644 --- a/cli/compose/loader/full-example.yml +++ b/cli/compose/loader/full-example.yml @@ -3,6 +3,8 @@ version: "3.13" services: foo: + apparmor: disabled + build: context: ./dir dockerfile: Dockerfile @@ -215,6 +217,8 @@ services: ipv6_address: 2001:3984:3989::10 other-other-network: + no_new_privileges: true + pid: "host" ports: @@ -232,6 +236,8 @@ services: restart: always + seccomp: unconfined + secrets: - secret1 - source: secret2 diff --git a/cli/compose/loader/full-struct_test.go b/cli/compose/loader/full-struct_test.go index 2aa512d09726..edea94f99852 100644 --- a/cli/compose/loader/full-struct_test.go +++ b/cli/compose/loader/full-struct_test.go @@ -33,6 +33,8 @@ func services(workingDir, homeDir string) []types.ServiceConfig { { Name: "foo", + AppArmor: "disabled", + Build: types.BuildConfig{ Context: "./dir", Dockerfile: "Dockerfile", @@ -201,7 +203,8 @@ func services(workingDir, homeDir string) []types.ServiceConfig { }, "other-other-network": nil, }, - Pid: "host", + NoNewPrivileges: true, + Pid: "host", Ports: []types.ServicePortConfig{ // "3000", { @@ -339,6 +342,7 @@ func services(workingDir, homeDir string) []types.ServiceConfig { Privileged: true, ReadOnly: true, Restart: "always", + Seccomp: "unconfined", Secrets: []types.ServiceSecretConfig{ { Source: "secret1", diff --git a/cli/compose/loader/testdata/full-example.json.golden b/cli/compose/loader/testdata/full-example.json.golden index c0ef39dabe37..b82c2beb4269 100644 --- a/cli/compose/loader/testdata/full-example.json.golden +++ b/cli/compose/loader/testdata/full-example.json.golden @@ -83,6 +83,7 @@ }, "services": { "foo": { + "apparmor": "disabled", "build": { "context": "./dir", "dockerfile": "Dockerfile", @@ -292,6 +293,7 @@ } } }, + "no_new_privileges": true, "pid": "host", "ports": [ { @@ -424,6 +426,7 @@ "privileged": true, "read_only": true, "restart": "always", + "seccomp": "unconfined", "secrets": [ { "source": "secret1" diff --git a/cli/compose/loader/testdata/full-example.yaml.golden b/cli/compose/loader/testdata/full-example.yaml.golden index ec925790adce..2c061427fb39 100644 --- a/cli/compose/loader/testdata/full-example.yaml.golden +++ b/cli/compose/loader/testdata/full-example.yaml.golden @@ -1,6 +1,7 @@ version: "3.13" services: foo: + apparmor: disabled build: context: ./dir dockerfile: Dockerfile @@ -155,6 +156,7 @@ services: driver_opts: driveropt1: optval1 driveropt2: optval2 + no_new_privileges: true pid: host ports: - mode: ingress @@ -242,6 +244,7 @@ services: privileged: true read_only: true restart: always + seccomp: unconfined secrets: - source: secret1 - source: secret2 diff --git a/cli/compose/schema/data/config_schema_v3.13.json b/cli/compose/schema/data/config_schema_v3.13.json index 8daa8892d625..39bd1116bae4 100644 --- a/cli/compose/schema/data/config_schema_v3.13.json +++ b/cli/compose/schema/data/config_schema_v3.13.json @@ -75,6 +75,7 @@ "properties": { "deploy": {"$ref": "#/definitions/deployment"}, + "apparmor": {"type": "string"}, "build": { "oneOf": [ {"type": "string"}, @@ -216,6 +217,7 @@ } ] }, + "no_new_privileges": {"type": "boolean"}, "pid": {"type": ["string", "null"]}, "ports": { @@ -244,6 +246,7 @@ "restart": {"type": "string"}, "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "shm_size": {"type": ["number", "string"]}, + "seccomp": {"type": "string"}, "secrets": { "type": "array", "items": { diff --git a/cli/compose/types/types.go b/cli/compose/types/types.go index 55b80365feca..a4e4699c6860 100644 --- a/cli/compose/types/types.go +++ b/cli/compose/types/types.go @@ -158,6 +158,7 @@ func (s Services) MarshalJSON() ([]byte, error) { type ServiceConfig struct { Name string `yaml:"-" json:"-"` + AppArmor string `yaml:"apparmor,omitempty" json:"apparmor,omitempty"` Build BuildConfig `yaml:",omitempty" json:"build,omitempty"` CapAdd []string `mapstructure:"cap_add" yaml:"cap_add,omitempty" json:"cap_add,omitempty"` CapDrop []string `mapstructure:"cap_drop" yaml:"cap_drop,omitempty" json:"cap_drop,omitempty"` @@ -191,11 +192,13 @@ type ServiceConfig struct { MacAddress string `mapstructure:"mac_address" yaml:"mac_address,omitempty" json:"mac_address,omitempty"` NetworkMode string `mapstructure:"network_mode" yaml:"network_mode,omitempty" json:"network_mode,omitempty"` Networks map[string]*ServiceNetworkConfig `yaml:",omitempty" json:"networks,omitempty"` + NoNewPrivileges bool `mapstructure:"no_new_privileges" yaml:"no_new_privileges,omitempty" json:"no_new_privileges,omitempty"` Pid string `yaml:",omitempty" json:"pid,omitempty"` Ports []ServicePortConfig `yaml:",omitempty" json:"ports,omitempty"` Privileged bool `yaml:",omitempty" json:"privileged,omitempty"` ReadOnly bool `mapstructure:"read_only" yaml:"read_only,omitempty" json:"read_only,omitempty"` Restart string `yaml:",omitempty" json:"restart,omitempty"` + Seccomp string `yaml:",omitempty" json:"seccomp,omitempty"` Secrets []ServiceSecretConfig `yaml:",omitempty" json:"secrets,omitempty"` SecurityOpt []string `mapstructure:"security_opt" yaml:"security_opt,omitempty" json:"security_opt,omitempty"` ShmSize string `mapstructure:"shm_size" yaml:"shm_size,omitempty" json:"shm_size,omitempty"`