© The Author(s), under exclusive license to APress Media, LLC, part of Springer Nature 2023
N. TolaramSoftware Development with Gohttps://doi.org/10.1007/978-1-4842-8731-6_6

6. Docker Security

Nanik Tolaram1  
(1)
Sydney, NSW, Australia
 

This chapter, you will look at seccomp profiles, one of the security features provided by Docker, which use the seccomp feature built into the Linux kernel. Standalone Go applications can also implement seccomp security without using Docker, and you will look at how to do this using the seccomp library.

You will also look at how Docker communicates using sockets by writing a proxy that listens to Docker communication. This is super useful to know because it gives you a better idea of how to secure Docker in your infrastructure.

Source Code

The source code for this chapter is available from the https://github.com/Apress/Software-Development-Go repository.

seccomp Profiles

seccomp is short for secure computing mode. It is a feature that is available inside the Linux operating system. Linux as an operating system provides this feature out of the box, which means that it is ready to be used. What is it actually? It is a security feature that allows applications to make only certain system calls, and this can be configured per application. As a developer, you can specify what kind of restriction you want to put in place so, for example, application A can only make system calls to read and write text files but it cannot make any other system calls, while application B can only make network system calls but can’t read or write files. You will look at how to do this in the application and how to make restrictions when running the application as a Docker container.

This kind of restriction provides more security for your infrastructure because you don't want an application to run on your infrastructure without any restrictions. seccomp, when used with Docker containers, provides more layers of security for the host operating system because it can be configured to allow certain system call access to applications that are currently running inside the container.

In order to use seccomp, first you must check whether your operating system supports it. Open your terminal and run the following command:
grep CONFIG_SECCOMP= /boot/config-$(uname -r)
If your operating supports seccomp, you will get the following output:
CONFIG_SECCOMP=y
If your Linux operating system does not support seccomp, you can install it using the package manager of your operating system. For example, in Ubuntu, you can install it using the following command:
sudo apt install seccomp

In the next section, you’ll see examples of how to use seccomp in a sample application.

libseccomp

In order to use the seccomp security feature inside the application, you must install the library. In this case, the library is called libseccomp (https://github.com/seccomp/libseccomp). Not all distros install the libseccomp by default, so you need to install it using your operating system package manager. In Ubuntu, you can install it by using the following command:
sudo apt  install libseccomp-dev
Now that the default seccomp library has been installed, you can start using it in your application. Run the sample application that is inside the chapter6/seccomp/libseccomp directory as follows:
go run main.go
You will get output as follows:
2022/07/05 22:11:34 Starting app
2022/07/05 22:11:34 Directory /tmp/NjAZmQrt created successfully
2022/07/05 22:11:34 Trying to get current working directory
2022/07/05 22:11:34 Current working directory is: <your_current_directory>
The code run by creating a temporary directory and reading the current directory using a system call is shown here:
package main
...
func main() {
  ...
  dirPath := "/tmp/" + randomString(8)
  if err := syscall.Mkdir(dirPath, 0600); err != nil {
     ...
  }
  ...
  wd, err := syscall.Getwd()
  if err != nil {
     ...
  }
  ...
}

What’s so special about the code? There is nothing special in what the code is doing. What’s special is the way you configured seccomp inside the sample code. The code uses a Go library called libseccomp-golang, which can be found at github.com/seccomp/libseccomp-golang.

The libseccomp-golang library is a Go binding library for the native seccomp library, which you installed in the previous section. You can think of the library as a wrapper to the C seccomp library that can be used inside the Go program. The library is used inside an application to configure itself, specifying what system calls it is allowed to make.

So why do you want to do this? Well, say you are working in a multiple-team environment and you want to make sure that the code written can only perform system calls that are configured internally. This will remove the possibility of introducing code that makes system calls that are not allowed in the configuration. Doing so will introduce an error and crash the application.

Looking at the snippet sample code, you can see the following allowable system calls, declared as string of an array in the whitelist variable:
var (
  whitelist   = []string{"getcwd", "exit_group", "rt_sigreturn", "mkdirat", "write"})
The listed system calls are the system calls that are required by the application. You will see later what happens if the code makes a system call that is not configured. The function configureSeccomp() is responsible for registering the defined system calls with the library.
func configureSeccomp() error {
  ...
  filter, err = seccomp.NewFilter(seccomp.ActErrno)
  ...
  for _, name := range whitelist {
     syscallID, err := seccomp.GetSyscallFromName(name)
     if err != nil {
        return err
     }
     err = filter.AddRule(syscallID, seccomp.ActAllow)
     if err != nil {
        return err
     }
  }
  ...
}

The first thing the function does is create a new filter by calling seccomp.NewFilter(..), passing in the action (seccomp.ActErrno) as parameter. The parameter specifies the action to be taken when the application calls system calls that are not allowed. In this case, you want it to return an error number.

Once it creates a new filter, it will loop through the whitelist system calls by first obtaining the correct system call id calling seccomp.GetSyscallFromName(..) and registering the id to the library using the filter.AddRule(..) function. The parameter seccomp.ActAllow specifies that the id is the system calls the application is allowed to make. On completion of the configureSeccomp() function, the application is configured to allow only the calls that have been white-listed.

The system calls that the application makes are simple. Create a file using the following snippet:
func main() {
  ...
  if err := syscall.Mkdir(dirPath, 0600); err != nil {
     return
  }
  ...
}
Get the current working directory using the following system call:
func main() {
  ...
  wd, err := syscall.Getwd()
  if err != nil {
     ...
  }
  ...
}
The question that pops up now is, what will happen if the application makes a system call that it is not configured for? Let’s modify the code a bit. Change the whitelist variable as follows:
var (
  whitelist = []string{
     "exit_group", "rt_sigreturn", "mkdirat", "write",
  }
  ...
)
This removed getcwd from the list. Now run the application. You will get an error as follows:
...
2022/07/05 22:53:06 Failed getting current working directory: invalid argument -

The code fails to make the system call to get the current working directory and returns an error. You can see that removing the registered system call from the list stops the application from functioning properly. In the next section, you will look at using seccomp for applications that run as containers using Docker.

Docker seccomp

Docker provides seccomp security for applications running in a container without having to add security inside the code. This is done by specifying the seccomp file when running the container. Open the file chapter6/dockerseccomp/seccomp.json to see what it looks like:
{
   "defaultAction": "SCMP_ACT_ERRNO",
   "architectures": [
       "SCMP_ARCH_X86_64"
   ],
   "syscalls": [
       {
           "names": [
               "arch_prctl",
               ...
               "getcwd"
           ],
           "action": "SCMP_ACT_ALLOW"
       }
   ]
}
The syscalls section outlines the different system calls that are permitted inside the container. Let’s build a docker container using the Dockerfile inside the chapter6/dockerseccomp directory. Open your terminal and change the directory to chapter6/dockerseccomp and run the following command:
docker build -t docker-seccomp:latest -f Dockerfile .
This will build the sample main.go inside that directory and package it into a container. Executing docker images shows the following image from your local repository:
REPOSITORY                 TAG           IMAGE ID      CREATED          SIZE
...
docker-seccomp             latest        4cebeb0b7fce   47 hours ago     21.3MB
...
gcr.io/distroless/base-debian10   latest a5880de4abab   52 years ago    19.2MB
You now have container called docker-seccomp. Test the container by running it as follows:
docker run  docker-seccomp:latest
You will get the same output as when you run the sample in a terminal:
2022/07/07 12:04:12 Starting app
2022/07/07 12:04:12 Directory /tmp/QPRNrGAA created successfully
2022/07/07 12:04:12 Trying to get current working directory
2022/07/07 12:04:12 Current working directory is: /
The container works as expected, which is great. Now let’s add some restrictions into the container for the app using seccomp. To run a container with a seccomp restriction, use the following command. In this example, the seccomp file is chapter6/dockerseccomp/seccomp.json. Open terminal and run the following command:
docker run --security-opt="no-new-privileges" --security-opt seccomp=<directory_of_chapter6>/dockerseccomp/seccomp.json docker-seccomp:latest

This will execute the container and you will get the same output as previously. The reason why you are able to run the container without any problem even after adding seccomp is because the seccomp.json contains all the necessary permitted syscalls for the container.

Let’s remove some syscalls from seccomp.json. You have another file called problem_seccomp.json that has removed mkdirat and getcwd from the allowable syscall list. Run the following from your terminal:
docker run --security-opt="no-new-privileges" --security-opt seccomp=<directory_of_chapter6>/dockerseccomp/problem_seccomp.json docker-seccomp:latest
The container will not run successfully, and you will get the following output:
2022/07/07 12:12:18 Starting app
2022/07/07 12:12:18 Failed creating directory: operation not permitted

You have successfully run the container, applying restricted syscalls for the application.

In the next section, you will look at building a Docker proxy to listen to the Docker communication to understand how Docker actually works in terms of receiving a command and responding to it.

Docker Proxy

Docker comprises two main components: the client tool, which is normally called docker when you run from your terminal, and the server where it runs as a server/daemon and listens for incoming commands. The Docker client communicates with the server using what is known as socket, which is an endpoint that passes data between different processes. Docker uses what is known as a non-networked socket, which is mostly used for local machine communication and is called a Unix domain socket (or IPC socket).

Docker by default uses Unix socket /var/run/docker.sock to communicate between client and server, as shown in Figure 6-1.

A flow diagram of a docker communication includes a docker client, docker daemon, and local registry.

Figure 6-1

Docker communication /var/run/docker.sock

In this section, you will look at sample code of how to intercept the communication between Docker client and server. You will step through the code to understand what it is actually doing and how it is performed. The code is inside the chapter6/docker-proxy directory. Run it on your terminal as follows:
go run main.go
You will get the following output when it runs successfully:
2022/07/09 11:59:04 Listening on /tmp/docker.sock for Docker commands

The proxy is now ready and listening on /tmp/docker.sock for messages to use Docker so that it goes through the proxy and sets the DOCKER_HOST environment variable. The DOCKER_HOST variable is used by the Docker command line tool to know which Unix socket to use to send the commands.

For example, to use the proxy to print out the list of running containers, use the following command on your terminal:
DOCKER_HOST=unix:///tmp/docker.sock docker ps
On the terminal that is running the proxy, you will see the Docker output in JSON format. On my local machine, the output look as follows:
2022/07/09 16:33:02 [Request] : HEAD /_ping HTTP/1.1
Host: docker
User-Agent: Docker-Client/20.10.9 (linux)
2022/07/09 16:33:02 [Request] : GET /v1.41/containers/json HTTP/1.1
Host: docker
User-Agent: Docker-Client/20.10.9 (linux)
2022/07/09 16:33:02 [Response] : [
  {
    "Id": "56f68f7cafb7e5f8b1b1f6263ac6b26f4d47b7a06536842212d577ddf1910a11",
    "Names": [
      "/redis"
    ],
    "Image": "redis",
    "ImageID": "sha256:bba24acba395b778d9522a1adf5f0d6bba3e6094b2d298e71ab08828b880a01b",
    "Command": "docker-entrypoint.sh redis-server",
    "Created": 1657331859,
    ...
  },
  {
    "Id": "2ab2942c2591dcd8eba883a1d57f1183a1d99bafb60be8f17edf8794e9295e53",
    "Names": [
      "/postgres"
    ],
    "Image": "postgres",
    "ImageID": "sha256:1ee973e26c6564a04b427993f47091cd3ae4d5156fbd46d331b17a8e7ab45d39",
    "Command": "docker-entrypoint.sh postgres",
    "Created": 1657331853,
    ...
  }
]
The proxy prints out the request from the Docker client and the response from the Docker server into the console. The Docker command line still prints out as normal and the output look as follows:
CONTAINER ID   IMAGE      COMMAND                  CREATED       STATUS       PORTS                                       NAMES
56f68f7cafb7   redis      "docker-entrypoint.s..."   4 hours ago   Up 4 hours   0.0.0.0:6379->6379/tcp, :::6379->6379/tcp   redis
2ab2942c2591   postgres   "docker-entrypoint.s..."   4 hours ago   Up 4 hours   0.0.0.0:5432->5432/tcp, :::5432->5432/tcp   postgres

As you can see, the response that the Docker client receives is in the JSON format and it contains a lot of information. Now let’s dig into the code to understand how things work internally.

Figure 6-2 shows the command flow from client to proxy to Docker server. The communication from the Docker client passes through the proxy before reaching the Docker daemon.

A flow diagram of a docker communication includes a docker client, docker proxy, docker daemon, and local registry.

Figure 6-2

Docker communication using a proxy

The following snippet shows the code that listens to the socket /tmp/docker.sock:
func main() {
  in := flag.String("in", proxySocket, "Proxy docker socket")
  ...
  sock, err := net.Listen("unix", *in)
  if err != nil {
     log.Fatalf("Error : %v", err)
  }
  ...
}

The code uses the net.Listen(..) function passing in the parameter unix. The unix parameter indicates to the function that the requested socket is a non-networked Unix socket, which is handled differently internally in the net library.

Once the socket has been initialized successfully, the code will create a socket handler that will take care of processing the incoming request and outgoing response. This is performed by the function ServeHTTP, which is the function of the handler struct. The following snippet shows the declaration of the handler and calling http.Serve to inform the library of the handler that the socket sock will be using. The handler is created, passing in dsocket to be populated into the socket variable.
func main() {
  ...
  dhandler := &handler{dsocket}
  ...
  err = http.Serve(sock, dhandler)
  ...
}
With the socket ready to accept connections, the function ServeHTTP takes care of processing the request and response for all traffic. The first thing the function does is create a separate connection to the Docker socket.
func (h *handler) ServeHTTP(response http.ResponseWriter, request *http.Request) {
   conn, err := net.DialUnix(unix, nil, &net.UnixAddr{h.socket, unix})
  if err != nil {
     writeError(response, errCode, err)
     return
  }
  ...
}

The net.DialUnix(..) function creates a Unix socket using the h.socket value as the socket name; in this sample code, the value is var/run/docker.sock. The connection object returned by this function will be used as a bridge by the code to pass back and forth the request and response.

The code will forward the incoming request to the Docker socket, as shown in the following snippet:
func (h *handler) ServeHTTP(response http.ResponseWriter, request *http.Request) {
  ...
  err = request.Write(conn)
  ...
}
The request.Write(..) function forwards the incoming request to the original Docker socket that is pointed by the conn variable. Once the request is sent, the code needs to get a http/Response struct in order to read the response reply from the Docker socket. This is done in the following code snippet:
func (h *handler) ServeHTTP(response http.ResponseWriter, request *http.Request) {
  ...
  resp, err := http.ReadResponse(bufio.NewReader(conn), request)
  if err != nil {
     writeError(response, errCode, err)
     return
  }
  ...
}
The resp variable now contains the response from the original Docker socket and it will extract the relevant information and forward it back to the caller response stored inside the response object, as shown in the following code snippet:
func (h *handler) ServeHTTP(response http.ResponseWriter, request *http.Request) {
  ...
  response.WriteHeader(resp.StatusCode)
  reader := bufio.NewReader(resp.Body)
  for {
     line, _, err := reader.ReadLine()
      ...
     // write the response back to the caller
     response.Write(line)
     ...
  }
}

In the next section, you will look at how to configure your Dockerfile to minimize the container attack surface.

Container Attack Surface

Building applications for cloud environments requires building the application as a container image, which requires creating Dockerfiles. This section will show how to minimize risk when creating Docker images for Go applications.

The main thing to remember when building a Docker image is the final image that the application will be running on. The rule of thumb is to use the bare minimum base image to host your application. In the Docker world, the bare minimum base image is scratch. More detailed information about the scratch image can be found at https://hub.docker.com/_/scratch.

The sample Dockerfile that uses the scratch image can be found inside the chapter6/dockersecurity directory. Open terminal and change to the chapter6/dockersecurity directory and build the image as follows:
docker build -t sample:latest  .
Once it’s successfully built, you will get output in your terminal as shown:
Step 1/14 : FROM golang:1.18 as build
 ---> 65b2f1fa535f
Step 2/14 : COPY ./main.go .
 ---> 5164c620eaff
...
Step 10/14 : FROM scratch
 --->
...
Successfully built 1a977f4b1cec
Successfully tagged sample:latest
Run the newly created Docker image using the following command:
docker run sample:latest
You will get output that looks like the following:
2022/07/09 10:06:42 Hello, from inside Docker image
2022/07/09 10:06:42 Build using Go version  go1.18.2
The sample Dockerfile uses the scratch image, as shown in the following snippet:
FROM golang:1.18 as build
...
RUN go build -trimpath -v -a -o sample -ldflags="-w -s"
RUN useradd -u 12345 normaluser
FROM scratch
...
ENTRYPOINT ["/sample"]

In using the scratch image, you have minimized the attack surface of your container because this image does not have a lot of applications installed like other Docker images (example: Ubuntu, Debian, etc.).

Summary

In this chapter, you learned about Docker security. The first thing you looked at is seccomp and why it is useful. You looked at the sample code and how to restrict a Go application using sec. You looked at setting up libseccomp, which allows you to apply restrictions to your application as to what system calls it can make.

The next thing you looked at using the libseecomp-golang library in your application and how to apply system call restrictions inside your code. Applying restriction inside code is good, but it will be hard to keep changing this code once it is running in production, so you looked at using seccomp profiles when running Docker containers.

Lastly, you looked at a Docker proxy to intercept and understand the communication between a Docker client and server. You also dove into the proxy code to understand how the proxy works in forwarding requests and responses. Finally, you looked briefly at the best way to reduce container attack surface by writing a Dockerfile to use the scratch base image.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.138.134.229