Nowadays, the final product of most Git repositories is a Docker image, that is then used in a Kubernetes deployment. With security being a hot topic now (and for good reasons), it would be scanning the Docker images you create in the CI is vital.
In this piece, I’ll use GitHub Actions to build Docker images and then scan them for security vulnerabilities. The Docker image built in the CI is also pushed to GitHub’s Docker registry.
Creating a GitHub Actions workflow
The CI workflow we’re going to create has the following structure:
- Test
- Build image
- Scan image
The image built in the second step is then pushed to the GitHub Docker registry and again pulled from it in the third stage.
Create the test job
Assuming your GitHub repo has no CI configuration, we’re going to create a file in the path .github/workflows/ci.yaml
with the following content:
name: ci
on:
push:
branches:
- master
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [ 18.x ]
steps:
- name: Checkout 🛎️
uses: actions/checkout@v3
- name: Setup Node environment 🧱: Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- name: Install and test 🪲
run: |
npm ci
npm test
The on
attribute specifies the events that trigger this workflow to run. In this case, any push to the branch master
and every pull request triggers the CI workflow.
The job steps include the following:
- Checkout 🔔: Gets the code from the repo
- Setup Node environment 🧱: Installs Node.js 18 environment
- Install and test 🪲: Install dependencies and runs the tests
The test job here is merely an example of running the unit tests of a Node.js project. Feel free to alter it to match your project language and structure.
Although it’s not necessary to have a test job to build the Docker image, it’s a good practice, so I stuck to it.
Create the Docker build job
To create the Docker build job, I would first create two environment variables at the beginning of the file under on
:
on:
...
env:
DOCKER_IMAGE_TAG: ${{ github.ref == 'refs/heads/master' && 'prod-' || 'dev-' }}${{ github.sha }}
GITHUB_REGISTRY: ghcr.io
GITHUB_REPOSITORY: ${{ github.repository }}
Then, let’s create the build\_docker job
:
jobs:
test:
...
build_image:
permissions:
id-token: write
contents: read
packages: write
runs-on: ubuntu-latest
needs: [ test ]
steps:
- name: Checkout 🛎️
uses: actions/checkout@v2
- name: Log in to the Container registry 📦
uses: docker/login-action@v2
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image 🐳
uses: docker/build-push-action@v3
with:
push: true
tags: |
${{ env.GITHUB_REGISTRY }}/${{ env.GITHUB_REPOSITORY }}:${{ env.DOCKER_IMAGE_TAG }}
Let’s look into the code. The permissions section is needed as we want to push Docker images into GitHub’s registry.
The job runs on the latest Ubuntu version and requires the test job to finish. That’s to ensure we’re only creating Docker images for functioning code. If you skipped the test job, remove the needs line.
Now to the steps:
- Checkout 🔔: Gets the code from the repo. This is required to get the Dockerfile and its context.
- Log in to the Container registry 📦
- Build and push Docker image 🐳: In this step, we use the environment variables we introduced at the beginning of the file to specify the Docker image’s tag.
Create the scan job
Now, let’s add the Docker image scan job:
jobs:
test:
...
build_image:
...
scan_docker_image:
permissions:
id-token: read
contents: read
packages: read
runs-on: ubuntu-latest
needs: [ build_image ]
steps:
- name: Checkout 🛎️
uses: actions/checkout@v2
- name: Log in to the Container registry 📦
uses: docker/login-action@v2
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Scan Docker image 🐳
uses: snyk/actions/docker@master
continue-on-error: true
with:
image: ${{ env.GITHUB_REGISTRY }}/${{ env.GITHUB_REPOSITORY }}:${{ env.DOCKER_IMAGE_TAG }}
args: --file=Dockerfile --severity-threshold=high --sarif-file-output=snyk.sarif
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
- name: Upload Snyk report as sarif 📦
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: snyk.sarif
This step uses Snyk, the security scanning engine behind docker scan
. To use it, you need to create a free account and store its token in a secret:
- Sign up here
- Get the token as described here
- Add it to your GitHub repo’s CI secrets with the name
SNYK\_TOKEN
as explained in the appendix
Now on with the steps:
- Checkout 🛎️: The scanner performs better if it has access to the Dockerfile as well.
- Log in to the Container registry 📦: To get the Docker images we pushed there earlier.
- Scan Docker image 🐳: This job scans the Docker image and reports the vulnerabilities in a file called
snyk.sarif
. This file format is recognized by GitHub and can be shown in the PR — which is why we have the next step - Upload Snyk report as sarif 📦: Here we upload the
sarif
file we generated in the previous step and upload it to GitHub
The vulnerabilities uploaded to GitHub show up on your PR like this:
Conclusion
In this piece, we created a GitHub Actions workflow with 3 jobs that would run the tests, build the Docker image, push it to the GitHub registry, check it for security issues, and upload the vulnerability report so that GitHub would understand and display them in the PRs.
Appendix: Add CI Secret on GitHub
In your repo:
- Click on the settings tab.
- In the menu panel on the left, from the Security section, click on Secrets and variables.
- Then, from the newly appeared menu items, click on Actions.
- On the top right, click on the green button that says New repository secret.
- In the Name section, write down the desired name, e.g.
SNYK\_TOKEN
. - In the Secret section, paste the secret, e.g. the Snyk token you copied from Snyk website.
- Then, click the green button that says Add secret.