Ken Muse

Creating a CodeQL Image for ARC


If you’re using GitHub Advanced Security with your own runners or with Actions Runner Controller (ARC), you’ve likely encountered the need to create an environment that supports CodeQL analysis. Today, I’ll walk you through the steps to create a custom CodeQL Docker image for use with ARC. If you’re running virtual machines, the same steps apply but you’ll need to adapt these for how you build your virtual machine images.

Prerequisites

Before you can create an image, there are some articles you should be familiar with:

These articles contain the specifics of what you need to run CodeQL, but here are some important highlights:

  • Support on Linux is limited to Ubuntu 22.04 and 24.04 running on the x64 (AMD64) architecture with glibc 2.17 or better. While you may be able to get CodeQL to run on other distributions or architectures, it is not officially supported.
  • You’ll want your container to have at least 8GB of RAM and two vCPUs (but I recommend at least twice that for most codebases).
  • You must have git installed and it must be in the PATH.
  • You’ll also often need the languages you want to analyze to be installed. Generally speaking, for a universal image you will want at least Python 2, Python 3, Java, JavaScript (Node.js), and .NET to be available.

Thankfully, the default Actions Runner image (ghcr.io/actions/actions-runner:latest) meets several of these requirements, so it gives us a good starting point. The documents don’t explicitly mention it, but you will also want to install CodeQL on the image. The binaries are more than 1.6GB, so you want to ensure that it doesn’t need to be downloaded every time you run a job!

The three paths

There are three options for creating an image that will support CodeQL. The first option is to install each of the language dependencies manually. If you’re doing this, you also want to ensure that you create JAVA_HOME_$VERSION_$PLATFORM environment variables for each version of Java you install (for example, JAVA_HOME_11_X64), as well as JAVA_HOME pointing to a default version. This has two downsides, however. First, the workflows might not be able to take advantage of any language binaries that aren’t properly installed into the tool cache, so you may download some files a second time. Second, CodeQL has changed the way it’s versioned (which impacts how it’s stored in the tool cache) in the past. Any time this happens, you may need to update your folder structures to match.

The second option is the one I mention in my earlier post about how to create a tool cache. This involves creating a tool cache and populating it with CodeQL and each of the language dependencies (using actions/setup-java, actions/setup-python, etc). You will still need to configure the Java environment variables, but these Actions will do most of the rest of the work for you. An additional benefit of this approach is that your workflows can take advantage of the tool cache, speeding up the setup of your jobs.

If you take this approach, be aware that the tool cache on the GitHub-hosted runners is built manually and designed to match how the setup- Action expects to find the files structured. For some of the default tools, the hosted runners use symbolic links to point to the actual binaries. This is part of why the article deletes the existing tool cache. I wanted to ensure that the dependencies are installed in a predictable way each time.

The third option is a hybrid of the two approaches. I want to use this one today because it demonstrates how to create a Docker image using some of the same techniques that the Actions team uses to create the official GitHub hosted runner VM images. It reuses part of the image building process to craft the Docker image.

No, it is not possible to directly use the official GitHub hosted runner VM images as a base for your Docker image. The images are not available publicly. They are also configured to build a complete virtual machine disk image, which is not the same thing as a Docker image. The two formats are not compatible with each other, so the image could not be directly used for Docker or Kubernetes.

What you need to know about the process

The scripts are part of the actions/runner-images repository. The scripts are called to build up the virtual machine images, but they are well-organized and with some minor adjustments can be used as part of a Docker build process. The files you need are in the images/ubuntu directory.

Before the scripts are run to install the binaries, a few things have already happened:

  • The environment variable HELPER_SCRIPTS provides the full path to the images/ubuntu/scripts/helpers directory.
  • The environment variable INSTALLER_SCRIPT_FOLDER provides the full path to the images/ubuntu/toolsets directory.
  • The toolsets-2404.json file in the toolsets directory contains the list of tool versions and related information. This file is copied to toolset.json, which gets referenced in the scripts.
  • The permissions for all of the shell scripts are updated to be executable.
  • The file ${HELPER_SCRIPTS}/invoke-tests.sh is copied to create the file /usr/local/bin/invoke-tests. This script is used to run some PowerShell scripts that test the installation of the tools.
  • Some environment variables have been configured and written to /etc/environment, including AGENT_TOOLSDIRECTORY, the path to the tool cache.
  • Some prerequisites (such as lsb-release and unzip) are installed.
  • The root user is configured to be able to run sudo. This is important because the scripts have places where the code assumes the current user is root.

Building a Dockerfile

To use just the installation scripts you need for CodeQL, you will need to reproduce these steps in your Dockerfile. As always, there’s a few ways we can do this (and plenty of areas we can optimize!), but I want to focus on implementing the process itself. I’ll use some advanced Dockerfile features such as multistage builds and Heredocs, so the Dockerfile will start with a syntax declaration that ensures the Dockerfile is interpreted correctly. From there, I’ve annotated the Dockerfile to explain each step in the process.

  1# syntax=docker/dockerfile:1
  2
  3## Settings that we can change at build time, including the base image
  4## You can provide either a specific version or a completely different base image.
  5ARG RUNNER_VERSION=latest
  6ARG BASE_IMAGE=ghcr.io/actions/actions-runner:${RUNNER_VERSION}
  7ARG AGENT_TOOLSDIRECTORY=/tools
  8
  9## First stage of the build will create an updated base image.
 10FROM ${BASE_IMAGE} AS tools
 11
 12## Allow this variable to be set as part of this stage
 13ARG AGENT_TOOLSDIRECTORY
 14
 15## Configure the environment variable for the tools directory
 16## so the runner can use it (and more importantly, because
 17## the scripts expect it to be set to know where to install the tools).
 18ENV AGENT_TOOLSDIRECTORY=${AGENT_TOOLSDIRECTORY}
 19
 20## Change to the root user to install the tools (base image is using `runner`)
 21USER root
 22
 23## Use the heredoc syntax to run a series of commands using the Bash shell
 24## instead of the default `sh` shell. The script ends when a line with just
 25## EOF is encountered. By doing a lot of steps inside a single RUN command,
 26## it creates a single layer with the final layout instead of multiple
 27## layers representing diffs between each command.
 28RUN << EOF bash
 29  ## Configure the Bash error handling
 30  set -euxo pipefail
 31 
 32  ## Update the package list so that we can install some packages
 33  apt-get update
 34
 35  ## Quietly install the prerequisites we need to run the scripts
 36  apt-get install -qqq -y wget lsb-release unzip
 37
 38  ## Clone the repository. The repository is 19M, I like small and fast!
 39  ## As a blobless clone, it just downloads the list of files and directories
 40  ## (trees), reducing the size of the clone to 8.4M. Since it is also
 41  ## a shallow clone (depth 1), it only downloads the latest commit -- 332K.
 42  ## By making it sparse, I only download the blobs for files in the root
 43  ## directory, `images`, and `images/ubuntu` (three "cone"). The final size
 44  ## is about 1.2M, or less than 10% of the original repository size!
 45  git clone --sparse --filter=blob:none --depth 1 https://github.com/actions/runner-images
 46  
 47  ## Move into the repository directory, but remember where we are for later.
 48  pushd runner-images
 49
 50  ## Perform a sparse checkout. This will cause the blobs for the "cone"
 51  ## `images/ubuntu` to be downloaded and placed in the working directory.
 52  ## You will now have all of the directories and files below `images/ubuntu`
 53  ## (plus the files -- but not the directories -- from `images`)
 54  git sparse-checkout add images/ubuntu
 55  
 56  ## Move into the `images/ubuntu` directory to set everything else up.
 57  ## I'm not using pushd because I won't need to return back to the parent
 58  cd images/ubuntu
 59  
 60  ## Set some environment variables for the script. AGENT_TOOLSDIRECTORY
 61  ## is already set for the image. These just need to be set for this script.
 62  ## Because Docker tries to populate any variables in the RUN command,
 63  ## I use a slash to escape the dollar sign. That gets removed when
 64  ## the script is executed. For example ${AGENT_TOOLSDIRECTORY} is
 65  ## interpreted by Docker and populated before the script is run, but
 66  ## \${INSTALLER_SCRIPT_FOLDER} is converted to ${INSTALLER_SCRIPT_FOLDER}
 67  ## when the final script is executed inside the container.
 68  ## I want ${PWD} to be interpreted by the script (not Docker), so I
 69  ## escape it as well.
 70  export DEBIAN_FRONTEND=noninteractive
 71  export HELPER_SCRIPTS=\${PWD}/scripts/helpers
 72  export INSTALLER_SCRIPT_FOLDER=\${PWD}/toolsets
 73
 74  ## The directory is expected to exist
 75  mkdir -p "${AGENT_TOOLSDIRECTORY}"
 76
 77  ## Create the toolset file that the scripts will use
 78  cp "\${INSTALLER_SCRIPT_FOLDER}/toolset-2404.json" "\${INSTALLER_SCRIPT_FOLDER}/toolset.json"
 79  
 80  ## And make sure all of the .sh scripts are executable, since many 
 81  ## are missing that bit in the repository
 82  find "\${PWD}/scripts" -type f -name "*.sh" -exec chmod +x {} \;
 83  
 84  ## Instead of setting up PowerShell and making sure we can run the test
 85  ## scripts, I will create an empty script so that nothing breaks
 86  ## when the command is called
 87  echo "#!/bin/bash" > /usr/local/bin/invoke_tests
 88  chmod +x /usr/local/bin/invoke_tests
 89 
 90  ## Root use needs to be able to run sudo, but I want to leave the
 91  ## sudoers file like I found it. So, I will back it up, then
 92  ## add the line that allows `root` to run `sudo
 93  cp /etc/sudoers /etc/sudoers.bak
 94  echo 'root ALL=(ALL) ALL' >> /etc/sudoers
 95
 96  ## FINALLY! We can now run the scripts to install the tools!
 97
 98  ## Configure Java and set it up in the tool cache (lots of symlinks!)
 99  ./scripts/build/install-java-tools.sh
100
101  ## Install and configure the latest version of CodeQL (huge!)
102  ./scripts/build/install-codeql-bundle.sh
103
104  ## Install Node.js. This one is not being installed in the tool cache.
105  ## so you might consider doing that yourself using the logic in
106  ## the `actions/setup-node` Action.
107  ./scripts/build/install-nodejs.sh
108
109  ## Wait! What? No Python? I'll explain in a bit ...
110
111  ## Restore the sudoers file back to its original state
112  mv -f /etc/sudoers.bak /etc/sudoers
113 
114  ## And remove invoke_tests, since it doesn't need to remain in the image.
115  rm /usr/local/bin/invoke_tests 
116
117  ## Update the permissions on the tools directory so that the
118  ## default user, `runner`, can access it.
119  chown -R runner:docker "${AGENT_TOOLSDIRECTORY}"
120
121  ## Return back to the parent directory so we can also remove
122  ## the entire Git repository.
123  popd
124  rm -rf runner-images
125
126  ## Finally, clean up the package cache and temporary files.
127  rm -rf /var/lib/apt/lists/* 
128  rm -rf /tmp/*
129EOF
130
131## And now we can return the image to using the `runner` user.
132USER runner

Hopefully all of those steps make sense. Obviously, some of these things can be setup or cached outside of the Dockerfile, but this updates the image to have most of the necessary bits for CodeQL.

Building the image

If you’re using Docker, you can build the image using the following command:

1docker build --target tools -t codeql-base:latest --platform linux/amd64 .

The platform option is important because CodeQL only officially supports the x64 (AMD64) architecture. In fact, these scripts are also really designed for x64 at the moment. As a result, you need to ensure that you are always building only for that architecture. Of course, if you’re on an Intel/AMD machine, that platform will be the default.

If you’re using Actions, you can use the default workflow that Actions provides for “Docker Publish”. You may want to explicitly set the platform here as well:

 1# Build and push Docker image with Buildx (don't push on PR)
 2# https://github.com/docker/build-push-action
 3- name: Build and push Docker image
 4  id: build-and-push
 5  uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
 6  with:
 7    context: .
 8    push: ${{ github.event_name != 'pull_request' }}
 9    tags: ${{ steps.meta.outputs.tags }}
10    labels: ${{ steps.meta.outputs.labels }}
11    target: tools
12    platforms: linux/amd64
13    cache-from: type=gha
14    cache-to: type=gha,mode=max

I’ve already talked about the various options you have available for your Docker layer caching, so I won’t repeat that here. For product images, I generally recommend the registry cache. While you can use the GitHub Actions cache, a single build of this image will use almost 2.5GB of space, which is a pretty big chunk of the 10GB limit for the cache. That means it takes just 3 PR branches (plus main) to start to overrun the cache limit if you’re using GHA caching.

One last thing – you may have noticed that nothing in the Dockerfile script (aside from escaping the dollar signs) is really specific to Docker. In fact, you could incorporate most of that code directly into the scripts used to build a virtual machine if you’re building standalone, non-ephemeral runners. The same logic applies. Of course, you might be able to adopt more of the Packer approach that’s in that repo, but that’s a topic for another day.

I think we missed a step

So, why didn’t I run the script to install and configure Python? It turns out the base image we’re using already has Python 3 installed, so we don’t need to do that a second time. Unfortunately, the script doesn’t configure Python 2, so that is something that we would need to implement manually. Unfortunately, Python 2 is no longer available in the Ubuntu repositories, so it requires building from source.

That’s why this Dockerfile is set up for multiple stages. Docker’s BuildKit can run multiple tasks in parallel, so we can allow this additional work to happen in a separate stage. That way, we can take just the binaries we need and copy them into the final image. This is a bit more work, so I’ll cover that in the next post.