From f1bee6ea00503527590814719e82892ced94d7be Mon Sep 17 00:00:00 2001 From: Drew Erny Date: Sat, 14 Dec 2024 10:34:03 -0600 Subject: [PATCH 1/2] add cli support for swarm security opts Adds CLI flags for setting some security options on services: * --seccomp to set seccomp mode or custom profile * --apparmor to default or disable apparmor * --no-new-privileges, same as with containers Signed-off-by: Drew Erny --- cli/command/service/opts.go | 103 +++++++++++++++++- cli/command/service/opts_test.go | 90 +++++++++++++++ .../service/testdata/test-seccomp-valid.json | 3 + 3 files changed, 190 insertions(+), 6 deletions(-) create mode 100644 cli/command/service/testdata/test-seccomp-valid.json 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..81ec0a0b3c67 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" +} From b10de33458bcac61ef57942eef19afae89d8e5f7 Mon Sep 17 00:00:00 2001 From: Drew Erny Date: Tue, 17 Dec 2024 07:22:11 -0600 Subject: [PATCH 2/2] WIP: Add seccomp/apparmor to docker stack DO NOT MERGE. Also I'll probably forget to redo this commit message even after this is merge ready but still DO NOT MERGE until I fix it. Adds seccomp, apparmor, and no-new-privileges flags to docker compose for docker stack command Signed-off-by: Drew Erny --- cli/compose/loader/full-example.yml | 6 ++++++ cli/compose/loader/full-struct_test.go | 6 +++++- cli/compose/loader/testdata/full-example.json.golden | 3 +++ cli/compose/loader/testdata/full-example.yaml.golden | 3 +++ cli/compose/schema/data/config_schema_v3.13.json | 3 +++ cli/compose/types/types.go | 3 +++ 6 files changed, 23 insertions(+), 1 deletion(-) 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"`