Skip to content

Commit

Permalink
add cli support for swarm security opts
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
dperny committed Dec 16, 2024
1 parent 91d097e commit 7ce478b
Show file tree
Hide file tree
Showing 3 changed files with 190 additions and 6 deletions.
103 changes: 97 additions & 6 deletions cli/command/service/opts.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ package service

import (
"context"
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
Expand Down Expand Up @@ -531,6 +533,10 @@ type serviceOptions struct {
ulimits opts.UlimitOpt
oomScoreAdj int64

seccomp string
appArmor string
noNewPrivileges bool

resources resourceOptions
stopGrace opts.DurationOpt

Expand Down Expand Up @@ -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.
//
Expand Down Expand Up @@ -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{
Expand All @@ -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,
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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"})
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -1008,6 +1097,7 @@ const (
flagRollbackOrder = "rollback-order"
flagRollbackParallelism = "rollback-parallelism"
flagInit = "init"
flagSeccomp = "seccomp"
flagSysCtl = "sysctl"
flagSysCtlAdd = "sysctl-add"
flagSysCtlRemove = "sysctl-rm"
Expand All @@ -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"
Expand Down
90 changes: 90 additions & 0 deletions cli/command/service/opts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}))
}
3 changes: 3 additions & 0 deletions cli/command/service/testdata/test-seccomp-valid.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"json": "you betcha"
}

0 comments on commit 7ce478b

Please sign in to comment.