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

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
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
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"
}
6 changes: 6 additions & 0 deletions cli/compose/loader/full-example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ version: "3.13"
services:
foo:

apparmor: disabled

build:
context: ./dir
dockerfile: Dockerfile
Expand Down Expand Up @@ -215,6 +217,8 @@ services:
ipv6_address: 2001:3984:3989::10
other-other-network:

no_new_privileges: true

pid: "host"

ports:
Expand All @@ -232,6 +236,8 @@ services:

restart: always

seccomp: unconfined

secrets:
- secret1
- source: secret2
Expand Down
6 changes: 5 additions & 1 deletion cli/compose/loader/full-struct_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ func services(workingDir, homeDir string) []types.ServiceConfig {
{
Name: "foo",

AppArmor: "disabled",

Build: types.BuildConfig{
Context: "./dir",
Dockerfile: "Dockerfile",
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -339,6 +342,7 @@ func services(workingDir, homeDir string) []types.ServiceConfig {
Privileged: true,
ReadOnly: true,
Restart: "always",
Seccomp: "unconfined",
Secrets: []types.ServiceSecretConfig{
{
Source: "secret1",
Expand Down
3 changes: 3 additions & 0 deletions cli/compose/loader/testdata/full-example.json.golden
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
},
"services": {
"foo": {
"apparmor": "disabled",
"build": {
"context": "./dir",
"dockerfile": "Dockerfile",
Expand Down Expand Up @@ -292,6 +293,7 @@
}
}
},
"no_new_privileges": true,
"pid": "host",
"ports": [
{
Expand Down Expand Up @@ -424,6 +426,7 @@
"privileged": true,
"read_only": true,
"restart": "always",
"seccomp": "unconfined",
"secrets": [
{
"source": "secret1"
Expand Down
3 changes: 3 additions & 0 deletions cli/compose/loader/testdata/full-example.yaml.golden
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
version: "3.13"
services:
foo:
apparmor: disabled
build:
context: ./dir
dockerfile: Dockerfile
Expand Down Expand Up @@ -155,6 +156,7 @@ services:
driver_opts:
driveropt1: optval1
driveropt2: optval2
no_new_privileges: true
pid: host
ports:
- mode: ingress
Expand Down Expand Up @@ -242,6 +244,7 @@ services:
privileged: true
read_only: true
restart: always
seccomp: unconfined
secrets:
- source: secret1
- source: secret2
Expand Down
3 changes: 3 additions & 0 deletions cli/compose/schema/data/config_schema_v3.13.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@

"properties": {
"deploy": {"$ref": "#/definitions/deployment"},
"apparmor": {"type": "string"},
"build": {
"oneOf": [
{"type": "string"},
Expand Down Expand Up @@ -216,6 +217,7 @@
}
]
},
"no_new_privileges": {"type": "boolean"},
"pid": {"type": ["string", "null"]},

"ports": {
Expand Down Expand Up @@ -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": {
Expand Down
Loading
Loading