diff --git a/cli/command/service/opts.go b/cli/command/service/opts.go index 9298cfb13145..06f820c968dd 100644 --- a/cli/command/service/opts.go +++ b/cli/command/service/opts.go @@ -5,7 +5,9 @@ package service import ( "context" + "encoding/json" "fmt" + "os" "sort" "strconv" "strings" @@ -531,6 +533,10 @@ type serviceOptions struct { ulimits opts.UlimitOpt oomScoreAdj int64 + seccomp string + appArmor string + noNewPrivileges bool + resources resourceOptions stopGrace opts.DurationOpt @@ -660,6 +666,84 @@ 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: + // TODO(dperny): return a better error + return nil, errors.Errorf("unknown AppArmor mode %q", options.appArmor) + } + } + + if flags.Changed(flagSeccomp) { + if privileges == nil { + privileges = &swarm.Privileges{} + } + switch options.seccomp { + case "default": + privileges.Seccomp = &swarm.SeccompOpts{ + Mode: swarm.SeccompModeDefault, + } + case "unconfined": + privileges.Seccomp = &swarm.SeccompOpts{ + Mode: swarm.SeccompModeUnconfined, + } + default: + // TODO(dperny): is it safe/secure to unconditionally read a file like + // this? what if the file is REALLY BIG? is that a user problem for + // passing a too-big file, or an us problem for ingesting it + // unquestioningly? + 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") + } + 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 +796,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 +819,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 +856,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 +970,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 +1025,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 +1097,7 @@ const ( flagRollbackOrder = "rollback-order" flagRollbackParallelism = "rollback-parallelism" flagInit = "init" + flagSeccomp = "seccomp" flagSysCtl = "sysctl" flagSysCtlAdd = "sysctl-add" flagSysCtlRemove = "sysctl-rm" @@ -1023,6 +1113,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..2eb522f273f5 100644 --- a/cli/command/service/opts_test.go +++ b/cli/command/service/opts_test.go @@ -326,3 +326,93 @@ 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)) +} + +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" +}