From bdc306bbfeef840a3f4b022d6b54d864b6029863 Mon Sep 17 00:00:00 2001 From: "Tim St. Clair" Date: Fri, 29 Jul 2016 17:13:32 -0700 Subject: [PATCH] Add AppArmor validation logic The validation checks the prerequisites described in the [AppArmor proposal](https://github.com/kubernetes/kubernetes/blob/master/docs/proposals/apparmor.md#prerequisites) --- pkg/security/apparmor/helpers.go | 49 +++++ pkg/security/apparmor/testdata/profiles | 24 +++ pkg/security/apparmor/validate.go | 207 +++++++++++++++++++++ pkg/security/apparmor/validate_disabled.go | 24 +++ pkg/security/apparmor/validate_test.go | 195 +++++++++++++++++++ 5 files changed, 499 insertions(+) create mode 100644 pkg/security/apparmor/helpers.go create mode 100644 pkg/security/apparmor/testdata/profiles create mode 100644 pkg/security/apparmor/validate.go create mode 100644 pkg/security/apparmor/validate_disabled.go create mode 100644 pkg/security/apparmor/validate_test.go diff --git a/pkg/security/apparmor/helpers.go b/pkg/security/apparmor/helpers.go new file mode 100644 index 0000000000..80695976b7 --- /dev/null +++ b/pkg/security/apparmor/helpers.go @@ -0,0 +1,49 @@ +/* +Copyright 2016 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package apparmor + +import ( + "strings" + + "k8s.io/kubernetes/pkg/api" +) + +// TODO: Move these values into the API package. +const ( + // The prefix to an annotation key specifying a container profile. + ContainerAnnotationKeyPrefix = "container.apparmor.security.alpha.kubernetes.io/" + + // The profile specifying the runtime default. + ProfileRuntimeDefault = "runtime/default" + // The prefix for specifying profiles loaded on the node. + ProfileNamePrefix = "localhost/" +) + +// Checks whether app armor is required for pod to be run. +func isRequired(pod *api.Pod) bool { + for key := range pod.Annotations { + if strings.HasPrefix(key, ContainerAnnotationKeyPrefix) { + return true + } + } + return false +} + +// Returns the name of the profile to use with the container. +func GetProfileName(pod *api.Pod, containerName string) string { + return pod.Annotations[ContainerAnnotationKeyPrefix+containerName] +} diff --git a/pkg/security/apparmor/testdata/profiles b/pkg/security/apparmor/testdata/profiles new file mode 100644 index 0000000000..48760a64c2 --- /dev/null +++ b/pkg/security/apparmor/testdata/profiles @@ -0,0 +1,24 @@ +/usr/bin/evince-thumbnailer (enforce) +/usr/bin/evince-thumbnailer//sanitized_helper (enforce) +/usr/bin/evince-previewer (enforce) +/usr/bin/evince-previewer//sanitized_helper (enforce) +/usr/bin/evince (enforce) +/usr/bin/evince//sanitized_helper (enforce) +/usr/lib/telepathy/telepathy-ofono (enforce) +/usr/lib/telepathy/telepathy-* (enforce) +/usr/lib/telepathy/telepathy-*//sanitized_helper (enforce) +/usr/lib/telepathy/telepathy-*//pxgsettings (enforce) +/usr/lib/telepathy/mission-control-5 (enforce) +/usr/lib/lightdm/lightdm-guest-session (enforce) +/usr/lib/lightdm/lightdm-guest-session//chromium (enforce) +/usr/sbin/tcpdump (enforce) +docker-default (enforce) +/usr/sbin/cups-browsed (enforce) +/usr/sbin/cupsd (enforce) +/usr/lib/cups/backend/cups-pdf (enforce) +/usr/sbin/ntpd (enforce) +/usr/lib/connman/scripts/dhclient-script (enforce) +/usr/lib/NetworkManager/nm-dhcp-client.action (enforce) +/sbin/dhclient (enforce) +foo-container (complain) +foo://namespaced-profile (enforce) diff --git a/pkg/security/apparmor/validate.go b/pkg/security/apparmor/validate.go new file mode 100644 index 0000000000..28952cbc83 --- /dev/null +++ b/pkg/security/apparmor/validate.go @@ -0,0 +1,207 @@ +/* +Copyright 2016 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package apparmor + +import ( + "bufio" + "errors" + "fmt" + "io/ioutil" + "os" + "path" + "strings" + + "k8s.io/kubernetes/pkg/api" + "k8s.io/kubernetes/pkg/util" +) + +// Whether AppArmor should be disabled by default. +// Set to true if the wrong build tags are set (see validate_disabled.go). +var isDisabledBuild bool + +// Interface for validating that a pod with with an AppArmor profile can be run by a Node. +type Validator interface { + Validate(pod *api.Pod) error +} + +func NewValidator(runtime string) Validator { + if err := validateHost(runtime); err != nil { + return &validator{validateHostErr: err} + } + appArmorFS, err := getAppArmorFS() + if err != nil { + return &validator{ + validateHostErr: fmt.Errorf("error finding AppArmor FS: %v", err), + } + } + return &validator{ + appArmorFS: appArmorFS, + } +} + +type validator struct { + validateHostErr error + appArmorFS string +} + +func (v *validator) Validate(pod *api.Pod) error { + if !isRequired(pod) { + return nil + } + + if v.validateHostErr != nil { + return v.validateHostErr + } + + loadedProfiles, err := v.getLoadedProfiles() + if err != nil { + return fmt.Errorf("could not read loaded profiles: %v", err) + } + + for _, container := range pod.Spec.InitContainers { + if err := validateProfile(GetProfileName(pod, container.Name), loadedProfiles); err != nil { + return err + } + } + for _, container := range pod.Spec.Containers { + if err := validateProfile(GetProfileName(pod, container.Name), loadedProfiles); err != nil { + return err + } + } + + return nil +} + +// Verify that the host and runtime is capable of enforcing AppArmor profiles. +func validateHost(runtime string) error { + // Check build support. + if isDisabledBuild { + return errors.New("Binary not compiled for linux.") + } + + // Check kernel support. + if !isAppArmorEnabled() { + return errors.New("AppArmor is not enabled on the host") + } + + // Check runtime support. Currently only Docker is supported. + if runtime != "docker" { + return fmt.Errorf("AppArmor is only enabled for 'docker' runtime. Found: %q.", runtime) + } + + return nil +} + +// Verify that the profile is valid and loaded. +func validateProfile(profile string, loadedProfiles map[string]bool) error { + if profile == "" || profile == ProfileRuntimeDefault { + return nil + } + if !strings.HasPrefix(profile, ProfileNamePrefix) { + return fmt.Errorf("invalid AppArmor profile name: %q", profile) + } + + profileName := strings.TrimPrefix(profile, ProfileNamePrefix) + if !loadedProfiles[profileName] { + return fmt.Errorf("profile %q is not loaded", profileName) + } + + return nil +} + +func (v *validator) getLoadedProfiles() (map[string]bool, error) { + profilesPath := path.Join(v.appArmorFS, "profiles") + profilesFile, err := os.Open(profilesPath) + if err != nil { + return nil, fmt.Errorf("failed to open %s: %v", profilesPath, err) + } + defer profilesFile.Close() + + profiles := map[string]bool{} + scanner := bufio.NewScanner(profilesFile) + for scanner.Scan() { + profileName := parseProfileName(scanner.Text()) + if profileName == "" { + // Unknown line format; skip it. + continue + } + profiles[profileName] = true + } + return profiles, nil +} + +// The profiles file is formatted with one profile per line, matching a form: +// namespace://profile-name (mode) +// profile-name (mode) +// Where mode is {enforce, complain, kill}. The "namespace://" is only included for namespaced +// profiles. For the purposes of Kubernetes, we consider the namespace part of the profile name. +func parseProfileName(profileLine string) string { + modeIndex := strings.IndexRune(profileLine, '(') + if modeIndex < 0 { + return "" + } + return strings.TrimSpace(profileLine[:modeIndex]) +} + +func getAppArmorFS() (string, error) { + mountsFile, err := os.Open("/proc/mounts") + if err != nil { + return "", fmt.Errorf("could not open /proc/mounts: %v", err) + } + defer mountsFile.Close() + + scanner := bufio.NewScanner(mountsFile) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) < 3 { + // Unknown line format; skip it. + continue + } + if fields[2] == "securityfs" { + appArmorFS := path.Join(fields[1], "apparmor") + if ok, err := util.FileExists(appArmorFS); !ok { + msg := fmt.Sprintf("path %s does not exist", appArmorFS) + if err != nil { + return "", fmt.Errorf("%s: %v", msg, err) + } else { + return "", errors.New(msg) + } + } else { + return appArmorFS, nil + } + } + } + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("error scanning mounts: %v", err) + } + + return "", errors.New("securityfs not found") +} + +// isAppArmorEnabled returns true if apparmor is enabled for the host. +// This function is forked from +// https://github.com/opencontainers/runc/blob/1a81e9ab1f138c091fe5c86d0883f87716088527/libcontainer/apparmor/apparmor.go +// to avoid the libapparmor dependency. +func isAppArmorEnabled() bool { + if _, err := os.Stat("/sys/kernel/security/apparmor"); err == nil && os.Getenv("container") == "" { + if _, err = os.Stat("/sbin/apparmor_parser"); err == nil { + buf, err := ioutil.ReadFile("/sys/module/apparmor/parameters/enabled") + return err == nil && len(buf) > 1 && buf[0] == 'Y' + } + } + return false +} diff --git a/pkg/security/apparmor/validate_disabled.go b/pkg/security/apparmor/validate_disabled.go new file mode 100644 index 0000000000..875054a941 --- /dev/null +++ b/pkg/security/apparmor/validate_disabled.go @@ -0,0 +1,24 @@ +// +build !linux + +/* +Copyright 2016 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package apparmor + +func init() { + // If Kubernetes was not built for linux, apparmor is always disabled. + isDisabledBuild = true +} diff --git a/pkg/security/apparmor/validate_test.go b/pkg/security/apparmor/validate_test.go new file mode 100644 index 0000000000..b200f5b02d --- /dev/null +++ b/pkg/security/apparmor/validate_test.go @@ -0,0 +1,195 @@ +/* +Copyright 2016 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package apparmor + +import ( + "errors" + "testing" + + "k8s.io/kubernetes/pkg/api" + + "github.com/stretchr/testify/assert" +) + +func TestGetAppArmorFS(t *testing.T) { + // This test only passes on systems running AppArmor with the default configuration. + // The test should be manually run if modifying the getAppArmorFS function. + t.Skip() + + const expectedPath = "/sys/kernel/security/apparmor" + actualPath, err := getAppArmorFS() + assert.NoError(t, err) + assert.Equal(t, expectedPath, actualPath) +} + +func TestValidateHost(t *testing.T) { + // This test only passes on systems running AppArmor with the default configuration. + // The test should be manually run if modifying the getAppArmorFS function. + t.Skip() + + assert.NoError(t, validateHost("docker")) + assert.Error(t, validateHost("rkt")) +} + +func TestValidateProfile(t *testing.T) { + loadedProfiles := map[string]bool{ + "docker-default": true, + "foo-bar": true, + "baz": true, + "/usr/sbin/ntpd": true, + "/usr/lib/connman/scripts/dhclient-script": true, + "/usr/lib/NetworkManager/nm-dhcp-client.action": true, + "/usr/bin/evince-previewer//sanitized_helper": true, + } + tests := []struct { + profile string + expectValid bool + }{ + {"", true}, + {"runtime/default", true}, + {"baz", false}, // Missing local prefix. + {"localhost//usr/sbin/ntpd", true}, + {"localhost/foo-bar", true}, + {"localhost/unloaded", false}, // Not loaded. + {"localhost/", false}, + } + + for _, test := range tests { + err := validateProfile(test.profile, loadedProfiles) + if test.expectValid { + assert.NoError(t, err, "Profile %s should be valid", test.profile) + } else { + assert.Error(t, err, "Profile %s should not be valid", test.profile) + } + } +} + +func TestValidateBadHost(t *testing.T) { + hostErr := errors.New("expected host error") + v := &validator{ + validateHostErr: hostErr, + } + + tests := []struct { + profile string + expectValid bool + }{ + {"", true}, + {"runtime/default", false}, + {"localhost/docker-default", false}, + } + + for _, test := range tests { + err := v.Validate(getPodWithProfile(test.profile)) + if test.expectValid { + assert.NoError(t, err, "Pod with profile %q should be valid", test.profile) + } else { + assert.Equal(t, hostErr, err, "Pod with profile %q should trigger a host validation error", test.profile) + } + } +} + +func TestValidateValidHost(t *testing.T) { + v := &validator{ + appArmorFS: "./testdata/", + } + + tests := []struct { + profile string + expectValid bool + }{ + {"", true}, + {"runtime/default", true}, + {"localhost/docker-default", true}, + {"localhost/foo-container", true}, + {"localhost//usr/sbin/ntpd", true}, + {"docker-default", false}, + {"localhost/foo", false}, + {"localhost/", false}, + } + + for _, test := range tests { + err := v.Validate(getPodWithProfile(test.profile)) + if test.expectValid { + assert.NoError(t, err, "Pod with profile %q should be valid", test.profile) + } else { + assert.Error(t, err, "Pod with profile %q should trigger a validation error", test.profile) + } + } + + // Test multi-container pod. + pod := &api.Pod{ + ObjectMeta: api.ObjectMeta{ + Annotations: map[string]string{ + "container.apparmor.security.alpha.kubernetes.io/init": "localhost/foo-container", + "container.apparmor.security.alpha.kubernetes.io/test1": "runtime/default", + "container.apparmor.security.alpha.kubernetes.io/test2": "localhost/docker-default", + }, + }, + Spec: api.PodSpec{ + InitContainers: []api.Container{ + {Name: "init"}, + }, + Containers: []api.Container{ + {Name: "test1"}, + {Name: "test2"}, + {Name: "no-profile"}, + }, + }, + } + assert.NoError(t, v.Validate(pod), "Multi-container pod should validate") + for k, val := range pod.Annotations { + pod.Annotations[k] = val + "-bad" + assert.Error(t, v.Validate(pod), "Multi-container pod with invalid profile %s:%s", k, pod.Annotations[k]) + pod.Annotations[k] = val // Restore. + } +} + +func TestParseProfileName(t *testing.T) { + tests := []struct{ line, expected string }{ + {"foo://bar/baz (kill)", "foo://bar/baz"}, + {"foo-bar (enforce)", "foo-bar"}, + {"/usr/foo/bar/baz (complain)", "/usr/foo/bar/baz"}, + } + for _, test := range tests { + name := parseProfileName(test.line) + assert.Equal(t, test.expected, name, "Parsing %s", test.line) + } +} + +func getPodWithProfile(profile string) *api.Pod { + annotations := map[string]string{ + "container.apparmor.security.alpha.kubernetes.io/test": profile, + } + if profile == "" { + annotations = map[string]string{ + "foo": "bar", + } + } + return &api.Pod{ + ObjectMeta: api.ObjectMeta{ + Annotations: annotations, + }, + Spec: api.PodSpec{ + Containers: []api.Container{ + { + Name: "test", + }, + }, + }, + } +}