Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add CLI flags for Swarm Service seccomp, AppArmor, and no-new-privileges #5698

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 123 additions & 6 deletions cli/command/service/opts.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ package service

import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
Expand Down Expand Up @@ -531,6 +534,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 +667,109 @@ 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{}
}
seccomp, err := options.ToSeccompOpts()
if err != nil {
return nil, err
}
privileges.Seccomp = seccomp
}

return privileges, nil
}

func (options *serviceOptions) ToSeccompOpts() (*swarm.SeccompOpts, error) {
switch arg := options.seccomp; arg {
case "default":
return &swarm.SeccompOpts{
Mode: swarm.SeccompModeDefault,
}, nil
case "unconfined":
return &swarm.SeccompOpts{
Mode: swarm.SeccompModeUnconfined,
}, nil
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,
)
}

return &swarm.SeccompOpts{
Mode: swarm.SeccompModeCustom,
Profile: data,
}, nil
}
}

// ToService takes the set of flags passed to the command and converts them
// into a service spec.
//
Expand Down Expand Up @@ -712,6 +822,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 +845,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 +882,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 +996,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 a path to a json custom seccomp profile)`))
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 +1051,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 +1123,7 @@ const (
flagRollbackOrder = "rollback-order"
flagRollbackParallelism = "rollback-parallelism"
flagInit = "init"
flagSeccomp = "seccomp"
flagSysCtl = "sysctl"
flagSysCtlAdd = "sysctl-add"
flagSysCtlRemove = "sysctl-rm"
Expand All @@ -1023,6 +1139,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
141 changes: 141 additions & 0 deletions cli/command/service/opts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package service
import (
"context"
"fmt"
"os"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -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}))
}
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"
}
26 changes: 26 additions & 0 deletions cli/compose/convert/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package convert

import (
"context"
"encoding/json"
"os"
"sort"
"strings"
Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading