diff --git a/.dockerignore b/.dockerignore index 720e7a0..ddcf96b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,7 +12,6 @@ **/*.dbmdl **/*.jfm **/bin -**/charts **/docker-compose* **/compose* **/Dockerfile* diff --git a/.github/workflows/build-app.yml b/.github/workflows/build-app.yml index 1317e0e..bc17239 100644 --- a/.github/workflows/build-app.yml +++ b/.github/workflows/build-app.yml @@ -2,7 +2,7 @@ name: "1. Create app" on: push: - branches: [main,dev] + branches: [main,dev,feature/*] paths: - "go/src/app/**.go" - Dockerfile @@ -142,4 +142,4 @@ jobs: env: CR_TOKEN: "${{ env.GITHUB_TOKEN }}" with: - config: ct.yaml + config: cr.yaml diff --git a/.gitignore b/.gitignore index 1eaddae..7c03081 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ # Editor configs .idea/ .vscode/ +.errors/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6d1d972 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,70 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +1. Go unit tests + - [ ] Create a new profile + - [ ] Update an existing profile + - [ ] Remove an existing profile + - [ ] Remove a non existing profile +1. Remove kubernetes Service and DaemonSet exposed ports if useless +1. Evaluate an automatic changelog generation from commits like [googleapis/release-please](https://github.com/googleapis/release-please) +1. Add daemonset commands for checking readiness +1. Add tests for all the main functions +1. Add test for checking current confinement state of the app +1. Test on multiple nodes cluster + + +## [0.1.0]() - 2023-02-01 +### Fixed +1. "Unable to replace profiles. Permission denied, app seems still confined." - Switched to ubuntu image +1. No need for SYS_ADMIN capabilities +1. Ignore hidden and system folders while scanning for profiles + +### Added +1. Instructions to test the app in a virtual machine directly running the go app or in microk8s pushing the built container to the local registry + + +## 0.0.6 - 2023-01-26 + +### Added +Helm: +- Added SYS_ADMIN capabilities to the daemonset +- Mounted needed folders in the Dockerfile and in the daemonset +- Added POLL_TIME and profiles files as configurable options through configmaps + +Go: +- Added first testing function +- Moved file operations functions to dedicated module + - Fixed POLL_TIME value passing from configmap + +CI/CD: +- Explicit changelog to help users understanding the project features + - Automatic generation of release notes based on changelog file +- Configurable poll time and profiles directory in the helm values file + +## [0.0.5](https://github.com/tuxerrante/kapparmor/releases/tag/kapparmor-0.0.5-alpha) - 2023-01-23 + +### Added + +Helm: +- Helm Chart based mainly on a DaemonSet and a configmap. No operator needed. +- Load all AppArmor profiles in the configmap template + +Go: +- Possibility to load continuously the security profiles from a configmap with a configurable poll time + +CI/CD: +- Helm chart linting and testing before releasing +- Security vulnerability tests on Go dependencies and container file. +- Auto generation of [GitHub pages](https://tuxerrante.github.io/kapparmor/) +- Container image tag is set to current commit SHA for every release. + +### Fixed + +- Being still an alpha release I will add everything in the "Added" section diff --git a/Dockerfile b/Dockerfile index 9af11a1..9dd519c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,27 +1,26 @@ # --- build stage -FROM golang:1.19-alpine AS builder -RUN apk add --no-cache git +FROM golang:1.19 AS builder + WORKDIR /go/src/app COPY . . RUN go get -d -v ./go/src/app/ RUN go build -o /go/bin/app -v ./go/src/app/ # --- -FROM alpine:latest +FROM ubuntu:latest LABEL Name=kapparmor Version=0.0.1 LABEL Author="Affinito Alessandro" WORKDIR /app -RUN addgroup --system appgroup &&\ - adduser --system appuser -G appgroup &&\ - apk --no-cache update &&\ - apk add apparmor - -COPY --chown=appuser:appgroup --from=builder ./go/bin/app /app/ -COPY --chown=appuser:appgroup ./charts/kapparmor/profiles /app/profiles +RUN apt-get update &&\ + apt-get upgrade -y &&\ + apt-get install --no-install-recommends --yes apparmor apparmor-utils &&\ + rm -rf /var/lib/apt/lists/* &&\ + mkdir --parent --verbose /etc/apparmor.d/custom -RUN chmod 550 app +COPY --from=builder /go/bin/app /app/ +COPY ./charts/kapparmor/profiles /app/profiles/ ARG PROFILES_DIR ARG POLL_TIME @@ -29,5 +28,5 @@ ARG POLL_TIME ENV PROFILES_DIR=$PROFILES_DIR ENV POLL_TIME=$POLL_TIME -USER appuser +USER root CMD ./app \ No newline at end of file diff --git a/Dockerfile.alpine b/Dockerfile.alpine new file mode 100644 index 0000000..ba0b34f --- /dev/null +++ b/Dockerfile.alpine @@ -0,0 +1,30 @@ +# --- build stage +FROM golang:1.19-alpine AS builder +RUN apk add --no-cache git +WORKDIR /go/src/app +COPY . . +RUN go get -d -v ./go/src/app/ +RUN go build -o /go/bin/app -v ./go/src/app/ + +# --- +FROM alpine:latest +LABEL Name=kapparmor Version=0.0.1 +LABEL Author="Affinito Alessandro" + +WORKDIR /app + +RUN apk --no-cache update &&\ + apk add apparmor libapparmor &&\ + mkdir --parent --verbose /etc/apparmor.d/custom + +COPY --from=builder ./go/bin/app /app/ +COPY ./charts/kapparmor/profiles /app/profiles + +ARG PROFILES_DIR +ARG POLL_TIME + +ENV PROFILES_DIR=$PROFILES_DIR +ENV POLL_TIME=$POLL_TIME + +USER root +CMD ./app \ No newline at end of file diff --git a/README.md b/README.md index eb26e16..542cf80 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,21 @@ # Kapparmor - [Kapparmor](#kapparmor) - - [Prerequisites](#prerequisites) - - [How to initialize this project again](#how-to-initialize-this-project-again) + - [Testing](#testing) + - [How to initialize this project](#how-to-initialize-this-project) - [Test the app locally](#test-the-app-locally) - - [TO-DO](#to-do) - [External useful links](#external-useful-links) - ----- Apparmor-loader project to deploy profiles through a kubernetes daemonset. -This work is inspired by [kubernetes/apparmor-loader](https://github.com/kubernetes/kubernetes/tree/master/test/images/apparmor-loader). ![architecture](./docs/kapparmor-architecture.png) +This app provide dynamic loading and unloading of AppArmor profiles to a Kubernetes cluster through a configmap. +The app doesn't need an operator and it will be managed by a DaemonSet filtering the linux nodes to schedule the app pod. +The custom profiles deployed in the configmap will be copied in a directory (`/etc/apparmor.d/custom` by default) since apparmor_parser needs the profiles definitions also to remove them. Once you will deploy a configmap with different profiles, Kapparmor will notice the missing ones and it will remove them from the apparmor cache and from the node directory. +If you modify only the content of a profile leaving the same name, Kapparmor should notice it anyway since a byte comparison is done when configmap profiles names and local profiles names match. + 1. The CD pipeline will - deploy a configmap in the security namespace containing all the profiles versioned in the current project - it will apply a daemonset on the linux nodes @@ -24,10 +27,14 @@ This work is inspired by [kubernetes/apparmor-loader](https://github.com/kuberne - The name of the file should be the same as the name of the profile. 3. The configmap will be polled every POLL_TIME seconds to move them into PROFILES_DIR host path and then enable them. -## Prerequisites +You can view which profiles are loaded on a node by checking the /sys/kernel/security/apparmor/profiles, so its parent will need to be mounted in the pod. + +This work was inspired by [kubernetes/apparmor-loader](https://github.com/kubernetes/kubernetes/tree/master/test/images/apparmor-loader). + +## Testing [Set up a Microk8s environment](./docs/microk8s.md). -### How to initialize this project again +### How to initialize this project ```sh helm create kapparmor sudo usermod -aG docker $USER @@ -38,6 +45,8 @@ go mod init ./go/src/app/ ``` ### Test the app locally + +Test Helm Chart creation ```sh # --- Check the Helm chart # https://github.com/helm/chart-testing/issues/464 @@ -49,27 +58,26 @@ docker run -it --network host --workdir=/data --volume ~/.kube/config:/root/.kub --volume $(pwd):/data quay.io/helmpack/chart-testing:latest \ /bin/sh -c "git config --global --add safe.directory /data; ct lint --print-config --charts ./charts/kapparmor" -export GITHUB_SHA=42 +# Replace here a commit id being part of an image tag +export GITHUB_SHA="sha-93d0dc4c597a8ae8a9febe1d68e674daf1fa919a" helm install --dry-run --atomic --generate-name --timeout 30s --debug --set image.tag=$GITHUB_SHA charts/kapparmor/ +``` +Test the app inside a container: +```sh # --- Build and run the container image docker build --quiet -t test-kapparmor --build-arg POLL_TIME=60 --build-arg PROFILES_DIR=/app/profiles -f Dockerfile . &&\ echo &&\ docker run --rm -it --privileged \ --mount type=bind,source='/sys/kernel/security',target='/sys/kernel/security' \ --mount type=bind,source='/etc',target='/etc'\ - test-kapparmor - + --name kapparmor test-kapparmor ``` -## TO-DO -1. Go unit tests - - [ ] Create a new profile - - [ ] Update an existing profile - - [ ] Remove an existing profile - - [ ] Remove a non existing profile -1. Remove kubernetes Service and DaemonSet exposed ports if useless + +To test Helm chart installation in a MicroK8s cluster, follow docs/microk8s.md instructions if you don't have any local cluster. + # External useful links diff --git a/charts/kapparmor/Chart.yaml b/charts/kapparmor/Chart.yaml index 85e7ba4..1293c80 100644 --- a/charts/kapparmor/Chart.yaml +++ b/charts/kapparmor/Chart.yaml @@ -5,8 +5,8 @@ type: application home: https://artifacthub.io kubeVersion: ">= 1.23.0-0" -version: "0.0.5-alpha" -appVersion: "0.0.1-alpha" +version: "0.0.6" +appVersion: "0.0.2" keywords: - kubernetes @@ -17,13 +17,3 @@ keywords: maintainers: - name: tuxerrante url: https://github.com/sponsors/tuxerrante - -annotations: - artifacthub.io/containsSecurityUpdates: "false" - artifacthub.io/changes: | - - kind: added - description: Load new profiles in the configmap - - kind: added - description: Unload old profiles in the filesystem - - kind: added - description: Update profiles with same name and different content diff --git a/charts/kapparmor/templates/configmap.yaml b/charts/kapparmor/templates/cm-profiles.yaml similarity index 68% rename from charts/kapparmor/templates/configmap.yaml rename to charts/kapparmor/templates/cm-profiles.yaml index 0de540c..fa7635a 100644 --- a/charts/kapparmor/templates/configmap.yaml +++ b/charts/kapparmor/templates/cm-profiles.yaml @@ -1,6 +1,6 @@ apiVersion: v1 kind: ConfigMap metadata: - name: {{ include "kapparmor.fullname" . }} + name: kapparmor-profiles data: {{ (.Files.Glob "profiles/*").AsConfig | indent 2 }} \ No newline at end of file diff --git a/charts/kapparmor/templates/cm-settings.yaml b/charts/kapparmor/templates/cm-settings.yaml new file mode 100644 index 0000000..9b49a5f --- /dev/null +++ b/charts/kapparmor/templates/cm-settings.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: kapparmor-settings +data: + PROFILES_DIR: "{{ .Values.app.profiles_dir }}" + POLL_TIME: "{{ .Values.app.poll_time }}" \ No newline at end of file diff --git a/charts/kapparmor/templates/daemonset.yaml b/charts/kapparmor/templates/daemonset.yaml index 7304abb..7d00e3c 100644 --- a/charts/kapparmor/templates/daemonset.yaml +++ b/charts/kapparmor/templates/daemonset.yaml @@ -10,12 +10,15 @@ spec: {{- include "kapparmor.selectorLabels" . | nindent 6 }} template: metadata: - {{- with .Values.podAnnotations }} annotations: - {{- toYaml . | nindent 8 }} - {{- end }} + container.apparmor.security.beta.kubernetes.io/kapparmor: unconfined + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: {{- include "kapparmor.selectorLabels" . | nindent 8 }} + spec: {{- with .Values.imagePullSecrets }} imagePullSecrets: @@ -24,36 +27,51 @@ spec: serviceAccountName: {{ include "kapparmor.serviceAccountName" . }} securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: - name: {{ .Chart.Name }} securityContext: {{- toYaml .Values.securityContext | nindent 12 }} image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" imagePullPolicy: {{ .Values.image.pullPolicy }} - ports: - - name: http - containerPort: 80 - protocol: TCP - livenessProbe: - httpGet: - path: / - port: http - readinessProbe: - httpGet: - path: / - port: http + resources: {{- toYaml .Values.resources | nindent 12 }} - # mount a configmap as read only in the container's filesystem. + volumeMounts : - - name : {{ include "kapparmor.fullname" . }} - mountPath : /app/profiles + # Folder containing profiles files mounted from the configmap + - name : kapparmor-profiles + mountPath : {{ .Values.app.profiles_dir }} readOnly : false + # Folder used by the kernel to store loaded profiles names + - name: profiles-kernel-path + mountPath: /sys/kernel/security + # Folder used by the app to store custom profiles definitions + - name: etc-apparmor + mountPath: /etc/apparmor.d/ + + env: + - name: PROFILES_DIR + valueFrom: + configMapKeyRef: + name: kapparmor-settings + key: PROFILES_DIR + - name: POLL_TIME + valueFrom: + configMapKeyRef: + name: kapparmor-settings + key: POLL_TIME volumes: - - name: {{ include "kapparmor.fullname" . }} + - name: kapparmor-profiles configMap: - name: {{ include "kapparmor.fullname" . }} + name: kapparmor-profiles + - name: profiles-kernel-path + hostPath: + path: /sys/kernel/security + - name: etc-apparmor + hostPath: + path: /etc/apparmor.d/ {{- with .Values.nodeSelector }} nodeSelector: diff --git a/charts/kapparmor/values.yaml b/charts/kapparmor/values.yaml index 86e6a80..c120db3 100644 --- a/charts/kapparmor/values.yaml +++ b/charts/kapparmor/values.yaml @@ -9,6 +9,10 @@ imagePullSecrets: [] nameOverride: "kapparmor" fullnameOverride: "" +app: + profiles_dir: "/app/profiles" + poll_time: 60 + serviceAccount: # Specifies whether a service account should be created create: false @@ -24,11 +28,8 @@ podSecurityContext: {} # fsGroup: 2000 securityContext: - # capabilities: - # drop: - # - ALL readOnlyRootFilesystem: false - runAsUser: 0 + privileged: true service: type: ClusterIP diff --git a/cr.yaml b/cr.yaml new file mode 100644 index 0000000..43f404c --- /dev/null +++ b/cr.yaml @@ -0,0 +1,12 @@ +# See https://github.com/helm/chart-testing#configuration +remote: origin +target-branch: main +chart-dirs: + - ./charts +chart-repos: + - kapparmor=https://tuxerrante.github.io/kapparmor/ +helm-extra-args: --timeout 600s +validate-maintainers: true +generate-release-notes: true +make-release-latest: false +release-notes-file: CHANGELOG.md \ No newline at end of file diff --git a/ct.yaml b/ct.yaml index 278fd76..7c8e870 100644 --- a/ct.yaml +++ b/ct.yaml @@ -1,11 +1,7 @@ -# See https://github.com/helm/chart-testing#configuration -remote: origin -target-branch: main +# See https://github.com/helm/chart-releaser chart-dirs: - ./charts chart-repos: - kapparmor=https://tuxerrante.github.io/kapparmor/ helm-extra-args: --timeout 600s validate-maintainers: true -generate-release-notes: true -make-release-latest: false diff --git a/docs/microk8s.md b/docs/microk8s.md index 0bcdb66..8cc64e3 100644 --- a/docs/microk8s.md +++ b/docs/microk8s.md @@ -12,8 +12,13 @@ restart # Install microk8s and check the status sudo snap install microk8s --classic --channel=latest/stable +microk8s enable dns hostpath-storage + microk8s inspect -aa-status |grep microk8s + +microk8s config > $HOME/.kube/microk8s.config + +sudo aa-status |grep microk8s # Check if the current machine results as an active node with apparmor enabled microk8s kubectl get nodes -o=jsonpath='{range .items[*]}{@.metadata.name}: {.status.conditions[?(@.reason=="KubeletReady")].message}{"\n"}{end}' @@ -34,6 +39,108 @@ Verify pods have some syscall blocked Blocked Syscalls (24): MSGRCV SYSLOG SETPGID SETSID VHANGUP PIVOT_ROOT ACCT SETTIMEOFDAY UMOUNT2 SWAPON SWAPOFF REBOOT SETHOSTNAME SETDOMAINNAME INIT_MODULE DELETE_MODULE LOOKUP_DCOOKIE KEXEC_LOAD PERF_EVENT_OPEN FANOTIFY_INIT OPEN_BY_HANDLE_AT FINIT_MODULE KEXEC_FILE_LOAD BPF +### HA setup +Microk8s doesn't support dynamic ips for nodes, so remember to fix it manually after machines reboot. +```sh +microk8s stop + +ip addr show enp0s8 |grep "inet " |awk '{print $2}' + +# Modify this with your ip config +cat >/etc/netplan/00-microk8s.yaml <> It was not possible to convert env var POLL_TIME %v to an integer.\n%v", POLL_TIME, err) + } + + // Check profiler binary + if _, err := os.Stat(PROFILER_BIN); os.IsNotExist(err) { + log.Fatal(err) + } + + // Check if custom directory exists + if _, err := os.Stat(ETC_APPARMORD); errors.Is(err, os.ErrNotExist) { + err := os.Mkdir(ETC_APPARMORD, os.ModePerm) + if err != nil { + log.Fatal(err) + } + log.Printf("> Directory %s created.", ETC_APPARMORD) + } + + return POLL_TIME +} + +// Compare the byte content of two given files +// The function supports also an external filesystem for testing and future usages +func HasTheSameContent(fsys fs.FS, filePath1, filePath2 string) (bool, error) { + + var file1, file2 os.FileInfo + + // Checking files on current filesystem + if fsys == nil { + fileBytes1, err := os.ReadFile(filePath1) + if err != nil { + log.Fatal(err) + } + fileBytes2, err := os.ReadFile(filePath2) + if err != nil { + log.Fatal(err) + } + if !bytes.Equal(fileBytes1, fileBytes2) { + return false, nil + } + return true, nil + } + + // dir will contain the files in given filesystem + dir, err := fs.ReadDir(fsys, ".") + if err != nil { + log.Printf("ERROR in opening directory %v\n", fsys) + return false, err + } + + log.Printf(" dir: %v, First file path: %v, Second file path: %v", dir, filePath1, filePath2) + + for _, file := range dir { + if filePath1 == file.Name() { + file1, _ = file.Info() + } else if filePath2 == file.Name() { + file2, _ = file.Info() + } + } + + if file1 == nil || file2 == nil { + return false, fmt.Errorf("ERROR: files not found") + } + + if file1.Size() != file2.Size() { + return false, nil + } + + f1, err := fsys.Open(file1.Name()) + if err != nil { + return false, err + } + defer f1.Close() + + f2, err := fsys.Open(file2.Name()) + if err != nil { + return false, err + } + defer f2.Close() + + return compareBytes(f1, f2) +} + +func compareBytes(f1, f2 fs.File) (bool, error) { + + data1, err := io.ReadAll(f1) + if err != nil { + return false, err + } + + data2, err := io.ReadAll(f2) + if err != nil { + return false, err + } + + if !bytes.Equal(data1, data2) { + return false, nil + } + + return true, nil +} + +func areProfilesReadable(FOLDER_NAME string) (bool, map[string]bool) { + + filenames := map[string]bool{} + files, err := os.ReadDir(FOLDER_NAME) + if err != nil { + log.Fatal(err.Error()) + } + + if len(files) == 0 { + log.Printf("No files were found in the given folder!\n") + return false, nil + } + + log.Printf("Found files in %s:\n", FOLDER_NAME) + for _, file := range files { + filename := file.Name() + if file.IsDir() { + log.Printf("Directory '%s' will be skipped.\n", filename) + continue + } else if strings.HasPrefix(filename, ".") { + log.Printf("'%s' will be skipped.\n", filename) + continue + } + log.Printf("- %s\n", filename) + filenames[filename] = true + } + + return true, filenames +} + +// CopyFile copies a file from src to dst. If src and dst files exist, and are +// the same, then return success. Otherwise, attempt to create a hard link +// between the two files. If that fail, copy the file contents from src to dst. +// Credits: https://stackoverflow.com/a/21067803/3673430 +func CopyFile(src, dst string) error { + + // dst is the destination directory + srcFileName := filepath.Base(src) + dstCompleteFileName := path.Join(ETC_APPARMORD, srcFileName) + + sfi, err := os.Stat(src) + if err != nil { + log.Fatal(err) + } + + if !sfi.Mode().IsRegular() { + // cannot copy non-regular files (e.g., directories symlinks, devices, etc.) + return fmt.Errorf("CopyFile: non-regular source file %s (%q)", sfi.Name(), sfi.Mode().String()) + } + + dfi, err := os.Stat(dstCompleteFileName) + if err != nil { + log.Print(err) + } else { + if !(dfi.Mode().IsRegular()) { + return fmt.Errorf("CopyFile: non-regular destination file %s (%q)", dfi.Name(), dfi.Mode().String()) + } + if os.SameFile(sfi, dfi) { + log.Printf("File %s is already present", dstCompleteFileName) + return nil + } + } + + if err = os.Link(src, dstCompleteFileName); err == nil { + log.Printf("Hard link created in %s", dstCompleteFileName) + return nil + } + + log.Printf("Copying %s in %s", src, dstCompleteFileName) + return copyFileContents(src, dstCompleteFileName) +} + +// copyFileContents copies the contents of the file named src to the file named +// by dst. The file will be created if it does not already exist. If the +// destination file exists, all it's contents will be replaced by the contents +// of the source file. +func copyFileContents(src, dst string) (err error) { + in, err := os.Open(src) + if err != nil { + log.Print(err) + return + } + defer in.Close() + + out, err := os.Create(dst) + if err != nil { + log.Print(err) + return + } + defer func() { + cerr := out.Close() + if err == nil { + err = cerr + } + }() + + if _, err = io.Copy(out, in); err != nil { + log.Print(err) + return + } + + err = out.Sync() + return +} diff --git a/go/src/app/hasTheSameContent_test.go b/go/src/app/hasTheSameContent_test.go new file mode 100644 index 0000000..0ab10db --- /dev/null +++ b/go/src/app/hasTheSameContent_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "testing" + "testing/fstest" +) + +const ( + testProfileData = ` +#include + +# a comment naming the application to confine +/usr/bin/foo { +#include + +link /etc/sysconfig/foo -> /etc/foo.conf, +}` + + testProfileDataExtraNewLine = ` +#include + +# a comment naming the application to confine +/usr/bin/foo { +#include + +link /etc/sysconfig/foo -> /etc/foo.conf, +} +` + testProfileDataDifferent = ` +#include + +# a comment naming the application to confine +/usr/bin/bar { +#include + +link /etc/sysconfig/foo -> /etc/foo.conf, +} +` +) + +func TestHasTheSameContent(t *testing.T) { + + fs := fstest.MapFS{ + "foo.profile": {Data: []byte(testProfileData)}, + "foo.profile.copy": {Data: []byte(testProfileData)}, + "foo.newline.profile": {Data: []byte(testProfileDataExtraNewLine)}, + "bar.profile": {Data: []byte(testProfileDataDifferent)}, + } + + t.Parallel() + + t.Run("Two profiles with same content", func(t *testing.T) { + got, err := HasTheSameContent(fs, "foo.profile", "foo.profile.copy") + want := true + ok(t, err) + assertBool(t, got, want) + }) + + t.Run("A profile with an extra newline", func(t *testing.T) { + // We don't forgive newlines + got, err := HasTheSameContent(fs, "foo.profile", "foo.newline.profile") + want := false + ok(t, err) + assertBool(t, got, want) + }) + + t.Run("Two different profiles", func(t *testing.T) { + // Very different profiles + got, err := HasTheSameContent(fs, "foo.profile", "bar.profile") + want := false + ok(t, err) + assertBool(t, got, want) + }) +} + +func ok(t testing.TB, err error) { + if err != nil { + t.Fatalf("Function call returned an error:\n %s", err) + } +} + +func assertBool(t *testing.T, got, want bool) { + t.Helper() + if got != want { + t.Fatalf("Bool check failed! Got %t, expected %t", got, want) + } +} diff --git a/go/src/app/main.go b/go/src/app/main.go index 897d6f6..5ec93ef 100644 --- a/go/src/app/main.go +++ b/go/src/app/main.go @@ -3,16 +3,12 @@ package main import ( "bufio" "bytes" - "crypto/sha256" - "errors" "fmt" - "io" "log" "os" "os/exec" "path" "sort" - "strconv" "strings" "time" ) @@ -20,6 +16,7 @@ import ( var ( CONFIGMAP_PATH string = os.Getenv("PROFILES_DIR") POLL_TIME_ARG string = os.Getenv("POLL_TIME") + POLL_TIME int ) const ( @@ -31,58 +28,13 @@ const ( func main() { - // Type check - POLL_TIME, err := strconv.Atoi(POLL_TIME_ARG) - if err != nil { - log.Fatalf(">> It was not possible to convert env var POLL_TIME %v to an integer.\n%v", POLL_TIME, err) - } + POLL_TIME = preFlightChecks() log.Printf("> Polling directory %s every %d seconds.\n", CONFIGMAP_PATH, POLL_TIME) - // Check profiler binary - if _, err := os.Stat(PROFILER_BIN); os.IsNotExist(err) { - log.Fatal(err) - } - - // Check if custom directory exists - if _, err := os.Stat(ETC_APPARMORD); errors.Is(err, os.ErrNotExist) { - err := os.Mkdir(ETC_APPARMORD, os.ModePerm) - if err != nil { - log.Fatal(err) - } - log.Printf("> Directory %s created.", ETC_APPARMORD) - } - - // Poll configmap forever every POLL_TIME seconds pollProfiles(POLL_TIME) } -func areProfilesReadable(FOLDER_NAME string) (bool, map[string]bool) { - - filenames := map[string]bool{} - files, err := os.ReadDir(FOLDER_NAME) - if err != nil { - log.Fatal(err.Error()) - } - - if len(files) == 0 { - log.Printf("No files were found in the given folder!\n") - return false, nil - } - - log.Printf("Found files in given folder:\n") - for _, file := range files { - if file.IsDir() { - log.Printf("Directory '%s' will be skipped.\n", file.Name()) - continue - } - log.Printf("- %s\n", file.Name()) - filenames[file.Name()] = true - } - - return true, filenames -} - // Profiles will probably change content while keeping the same name, so a digest comparison // can be useful if we don't want to load everything every time. func pollProfiles(delay int) { @@ -117,15 +69,21 @@ func loadNewProfiles() ([]string, error) { log.Fatalf(">> Error reading existing profiles.\n%v", err) } + // Clean eventually empty keys + delete(customLoadedProfiles, "") + delete(loadedProfiles, "") + // Sort alphabetically the profiles and print them - log.Println("Profiles already on this node:") + log.Printf("%d Profiles already on this node:", len(loadedProfiles)) loadedProfileNames := make([]string, len(loadedProfiles)) for loadedProfileName := range loadedProfiles { loadedProfileNames = append(loadedProfileNames, loadedProfileName) } sort.Strings(loadedProfileNames) for _, p := range loadedProfileNames { - log.Printf("- %s\n", p) + if p != "" { + log.Printf("- %s\n", p) + } } // Check if it exists a profile already loaded with the same name @@ -140,15 +98,15 @@ func loadNewProfiles() ([]string, error) { if customLoadedProfiles[newProfileName] { // If the profile is exactly the same skip the apply - // ERROR: it checks profiles still not applied - filePath2 := path.Join(ETC_APPARMORD, newProfileName) - contentIsTheSame, err := hasTheSameContent(filePath1, filePath2) + log.Printf("Checking %s profile..", path.Join(CONFIGMAP_PATH, newProfileName)) + contentIsTheSame, err := HasTheSameContent(nil, filePath1, path.Join(CONFIGMAP_PATH, newProfileName)) if err != nil { - log.Printf(">> Error in checking the content of %q VS %q\n", filePath1, filePath2) + // Error in checking the content of "/app/profiles/custom.deny-write-outside-app" VS "custom.deny-write-outside-app" + log.Printf(">> Error in checking the content of %q VS %q\n", filePath1, newProfileName) return nil, err } if contentIsTheSame { - log.Printf("Content of %q and %q seems the same, skipping.", filePath1, filePath2) + log.Print("Contents seems the same, skipping..") continue } } @@ -167,16 +125,21 @@ func loadNewProfiles() ([]string, error) { log.Println("============================================================") log.Println("> Apparmor replace and apply new profiles..") for _, profilePath := range newProfilesToApply { - loadProfile(profilePath) + err := loadProfile(profilePath) + if err != nil { + log.Printf("ERROR: %s", err) + } } // Execute apparmor_parser --remove obsoleteProfilePath - log.Println("============================================================") - log.Println("> AppArmor REMOVE orphans profiles..") - for _, profileFileName := range loadedProfilesToUnload { - err := unloadProfile(profileFileName) - if err != nil { - log.Fatal(err) + if len(loadedProfilesToUnload) > 0 { + log.Println("============================================================") + log.Println("> AppArmor REMOVE orphans profiles..") + for _, profileFileName := range loadedProfilesToUnload { + err := unloadProfile(profileFileName) + if err != nil { + log.Fatal(err) + } } } @@ -240,63 +203,24 @@ func parseProfileName(profileLine string) string { return strings.TrimSpace(profileLine[:modeIndex]) } -func hasTheSameContent(filePath1, filePath2 string) (bool, error) { - // compare sizes - file1, openErr1 := os.Open(filePath1) - if openErr1 != nil { - return false, openErr1 - } - defer file1.Close() - - file1_info, err := file1.Stat() - if err != nil { - log.Fatal("> Error accessing stats from file ", filePath1) - } - - file2, openErr2 := os.Open(filePath2) - if openErr2 != nil { - return false, openErr2 - } - defer file2.Close() - - file2_info, err := file2.Stat() +func loadProfile(profilePath string) error { + err := execApparmor("--verbose", "--replace", profilePath) if err != nil { - log.Fatal("> Error accessing stats from file ", filePath2) - } - - if file1_info.Size() != file2_info.Size() { - return false, nil - } - - // compare content through a sha256 hash - h1 := sha256.New() - if _, err := io.Copy(h1, file1); err != nil { - log.Fatal("Error in generating a hash for ", filePath1) - } - - h2 := sha256.New() - if _, err := io.Copy(h2, file2); err != nil { - log.Fatal("Error in generating a hash for ", filePath2) - } - - // Sum appends the current hash to nil and returns the resulting slice - if bytes.Equal(h1.Sum(nil), h2.Sum(nil)) { - log.Printf("> Hashes are different\n %s: %s\n %s: %s", filePath1, h1, filePath2, h2) - return false, nil + log.Fatal(err) } - return true, nil -} - -func loadProfile(profilePath string) error { - execApparmor("--verbose", "--replace", profilePath) // Copy the profile definition in the apparmor configuration standard directory + log.Printf("Copying profile in %s", ETC_APPARMORD) return CopyFile(profilePath, ETC_APPARMORD) } func unloadProfile(fileName string) error { filePath := path.Join(ETC_APPARMORD, fileName) - execApparmor("--verbose", "--remove", filePath) + + err := execApparmor("--verbose", "--remove", filePath) + if err != nil { + return err + } return os.Remove(filePath) } @@ -306,7 +230,7 @@ func execApparmor(args ...string) error { cmd.Stderr = stderr out, err := cmd.Output() path := args[len(args)-1] - log.Printf(" Loading profiles from %s:\n%s", path, out) + log.Printf("Loading profiles from %s:\n%s", path, out) if err != nil { if stderr.Len() > 0 { log.Println(stderr.String()) @@ -316,63 +240,3 @@ func execApparmor(args ...string) error { return nil } - -// CopyFile copies a file from src to dst. If src and dst files exist, and are -// the same, then return success. Otherwise, attempt to create a hard link -// between the two files. If that fail, copy the file contents from src to dst. -// Credits: https://stackoverflow.com/a/21067803/3673430 -func CopyFile(src, dst string) (err error) { - sfi, err := os.Stat(src) - if err != nil { - return - } - if !sfi.Mode().IsRegular() { - // cannot copy non-regular files (e.g., directories symlinks, devices, etc.) - return fmt.Errorf("CopyFile: non-regular source file %s (%q)", sfi.Name(), sfi.Mode().String()) - } - dfi, err := os.Stat(dst) - if err != nil { - if !os.IsNotExist(err) { - return - } - } else { - if !(dfi.Mode().IsRegular()) { - return fmt.Errorf("CopyFile: non-regular destination file %s (%q)", dfi.Name(), dfi.Mode().String()) - } - if os.SameFile(sfi, dfi) { - return - } - } - if err = os.Link(src, dst); err == nil { - return - } - err = copyFileContents(src, dst) - return -} - -// copyFileContents copies the contents of the file named src to the file named -// by dst. The file will be created if it does not already exist. If the -// destination file exists, all it's contents will be replaced by the contents -// of the source file. -func copyFileContents(src, dst string) (err error) { - in, err := os.Open(src) - if err != nil { - return - } - defer in.Close() - out, err := os.Create(dst) - if err != nil { - return - } - defer func() { - cerr := out.Close() - if err == nil { - err = cerr - } - }() - if _, err = io.Copy(out, in); err != nil { - return - } - err = out.Sync() - return -} diff --git a/test/cm-kapparmor-home-profile.yml b/test/cm-kapparmor-home-profile.yml new file mode 100644 index 0000000..129b216 --- /dev/null +++ b/test/cm-kapparmor-home-profile.yml @@ -0,0 +1,17 @@ +apiVersion: v1 +data: + custom.deny-write-outside-home: |- + profile custom.deny-write-outside-home flags=(attach_disconnected) { + file, # access all filesystem + /home/** rw, + deny /bin/** w, # deny writes in all subdirectories + deny /etc/** w, + deny /usr/** w, + } +kind: ConfigMap +metadata: + annotations: + meta.helm.sh/release-name: kapparmor + labels: + app.kubernetes.io/managed-by: Helm + name: kapparmor-profiles diff --git a/test/ubuntu_deploy.yml b/test/ubuntu_deploy.yml new file mode 100644 index 0000000..ccacac0 --- /dev/null +++ b/test/ubuntu_deploy.yml @@ -0,0 +1,64 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: ubuntu + name: ubuntu +spec: + replicas: 1 + selector: + matchLabels: + app: ubuntu + strategy: {} + template: + metadata: + labels: + app: ubuntu + annotations: + container.apparmor.security.beta.kubernetes.io/ubuntu: unconfined + spec: + containers: + - image: ubuntu:22.10 + name: ubuntu + resources: + requests: + cpu: "100m" + memory: "20Mi" + limits: + memory: "200Mi" + command: ["bash", "-c"] + args: ["sleep infinity"] + + volumeMounts : + - name : kapparmor-profiles + mountPath : /app/profiles + readOnly : false + - name: profiles-kernel-path + mountPath: /sys/kernel/security + - name: etc-apparmor + mountPath: /etc/apparmor.d/ + env: + - name: PROFILES_DIR + valueFrom: + configMapKeyRef: + name: kapparmor-settings + key: PROFILES_DIR + - name: POLL_TIME + valueFrom: + configMapKeyRef: + name: kapparmor-settings + key: POLL_TIME + securityContext: + privileged: true + readOnlyRootFilesystem: false + + volumes: + - name: kapparmor-profiles + configMap: + name: kapparmor-profiles + - name: profiles-kernel-path + hostPath: + path: /sys/kernel/security + - name: etc-apparmor + hostPath: + path: /etc/apparmor.d/ \ No newline at end of file