Build, Scan and Push containers with Azure DevOps, GitHub and Trivy

8 minute read

Building upon my previous posts regarding Trivy with Azure DevOps and Github where we scanned pre-built images, I thought we should approach it from a “real-world” perspective, or at least how I use Trivy.

You’re probably thinking “why weren’t the other two posts real-world?” well, the truth is they absolutely are. You could trigger an action to scan a pre-built image for a whole host of different reasons and in reality, what I’m about to describe in this post isn’t really any different. The only difference really is that before I push my container image to any registry, I’m going to scan the locally (to the build agent) built image.

My opinion is that the order of things should be as follows:

  • Build the Docker image
  • Scan with Trivy
  • If it succeeds, push it to Docker Hub
  • If it fails, fail the build

Why? We should build the application, run unit tests and build the image. Then we should scan the image, but if there’s a severe vulnerability we should stop that flawed image from being pushed to the registry and having those vulnerabilities making their way to production.

I’ve also put everything included in this post in a Github repository. I’m using the generic ASP.NET Core MVC start project you get when you run dotnet new mvc and I’ve added Docker support with the Dockerfile that gets created by Visual Studio too. The Dockerfile is included below:

#See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging.

FROM mcr.microsoft.com/dotnet/core/aspnet:3.1-buster-slim AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443

FROM mcr.microsoft.com/dotnet/core/sdk:3.1-buster AS build
WORKDIR /src
COPY ["ContainerScanning.HelloWorld/ContainerScanning.HelloWorld.csproj", "ContainerScanning.HelloWorld/"]
RUN dotnet restore "ContainerScanning.HelloWorld/ContainerScanning.HelloWorld.csproj"
COPY . .
WORKDIR "/src/ContainerScanning.HelloWorld"
RUN dotnet build "ContainerScanning.HelloWorld.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "ContainerScanning.HelloWorld.csproj" -c Release -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "ContainerScanning.HelloWorld.dll"]

Rather than covering it in two near identical posts, I’m going to cover both Azure DevOps and Github here too. I’ll also assume you already have a repository or at least know how to create one in Azure DevOps and Github.

Building a Docker image, scanning it with Trivy and pushing it to Docker Hub in Azure DevOps and Github

Setting up the basics

Azure DevOps

We’re going to need to create a service connection to Docker Hub (or a registry of your choice). To do this in Azure DevOps go to Project settings > Service connections > New service connection > Docker registry and hit the next button.

Set the same settings as above and your user name and password, then click the verify and save button.

Github

For Github, the action defaults to Docker hub already - we just need to provide credentials. You can do this by setting up secrets that we’ll use later. In your repository, click on Settings > Secrets and create a new secret for your user name and password.

Creating the build YAML

Next, create a new build yaml file.

Let’s setup a few variables to get us going. I like to tag my images with the build number and not the build ID so that its easier to trace at a glance through the pipeline. As an aside, I’m including azuredevops- and in the actions version github- because on a successful push to the registry, I want to see which tool the image has come from for my demos.

While we’re at it, let’s go ahead and name the image too with <repo>/<imagename>.

Azure DevOps

trigger:
- master

resources:
- repo: self

variables:
  trivyVersion: 0.9.1
  tag: 'azuredevops-$(Build.BuildNumber)'
  imageName: 'liamgu/container-scanning-demo'

A quick reminder of what we have here:

  • We’ve set the CI trigger to run from commits to master
  • Specified we need the repo the yaml is in
  • Set our tag and image name
  • Set our Trivy version, image tag and image name

Github

name: Build-Scan-Push

# Controls when the action will run. Triggers the workflow on push or pull request
# events but only for the master branch
on:
  push:
    branches: [ master ]
  pull_request:
    branches: [ master ]

env:
  BuildNumber: ${{ github.run_id }}-${{ github.run_number }}
  ImageName: 'liamgu/container-scanning-demo'

jobs:
  # This workflow contains a single job called "build"
  build:
    # The type of runner that the job will run on
    runs-on: ubuntu-latest

    # Steps represent a sequence of tasks that will be executed as part of the job
    steps:
    # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
    - uses: actions/checkout@v2

With Github Actions we’ve done the following to get started:

  • Set CI triggers to run off of commits to master
  • Set a build number as an environment variable. We’ll also use this as our tag
  • Set our image name
  • Added the action to checkout the repo

Build the Docker image

Now let’s go ahead and add in the stage, pool and task to build the image.

Azure DevOps

stages:
- stage: Build
  displayName: Build, Scan and Push image
  jobs:  
  - job: Build
    displayName: Build, Scan and Push
    pool:
      vmImage: 'ubuntu-latest'
    steps:
    - task: Docker@2
      displayName: Build an image
      inputs:
        containerRegistry: 'Docker hub' #the name of the service connection
        repository: '$(imageName)'
        command: 'build'
        Dockerfile: '**/Dockerfile'
        buildContext: '$(Build.SourcesDirectory)/src/'
        tags: '$(tag)'

One important thing here is that we set the build context for the docker build command. This will set the context of where all the actions for building the dockerfile will run from. The above is set up for running my demo project. It will only build and tag the image at this stage. We’re also accessing our variables with $(<variable name>).

Github

    - name: Build Docker image
      uses: docker/build-push-action@v1.1.0
      with:
        # Username used to log in to a Docker registry. If not set then no login will occur
        username: ${{ secrets.DockerHubUser }}  
        # Password or personal access token used to log in to a Docker registry. If not set then no login will occur
        password: ${{ secrets.DockerHubPassword }}
        # Docker repository to tag the image with
        repository: ${{ env.ImageName }}
        # Comma-delimited list of tags. These will be added to the registry/repository to form the image's tags
        tags: 'github-${{ env.BuildNumber }}'
        # Path to the build context
        path: 'src'
        # Path to the Dockerfile (Default is '{path}/Dockerfile')
        dockerfile: 'src/ContainerScanning.HelloWorld/Dockerfile'
        # Whether to push the image
        push: false

This use the Docker Build and Push action by Docker themselves.

It looks a lot different to the Azure DevOps flavour of the task though ultimately it does the same thing. Personally I feel the Azure DevOps flavour is cleaner but I’m not sure it matters as they work the same way pretty much.

Here, we’re also setting the build context, but via path instead. We’re also accessing our secrets and environment variables with ${{ <secret or env>.<Name> }}.

It pushes by default so for this, let’s set push to false.

Run the Trivy Scan

Now let’s go ahead and add in the Trivy steps with a couple of changes.

Azure DevOps

    - script: |
        sudo apt-get install rpm
        wget https://github.com/aquasecurity/trivy/releases/download/v$(trivyVersion)/trivy_$(trivyVersion)_Linux-64bit.deb
        sudo dpkg -i trivy_$(trivyVersion)_Linux-64bit.deb
        trivy -v
      displayName: 'Download and install Trivy'

    - task: CmdLine@2
      displayName: "Run trivy scan"
      inputs:
        script: |
            trivy image --exit-code 0 --severity LOW,MEDIUM $(imageName):$(tag)
            trivy image --exit-code 1 --severity HIGH,CRITICAL $(imageName):$(tag)

For the most part, they’re the same as my previous post with only one minor change to make it use the image that has just been built and that’s to change the image used to our variables from earlier. Using $(imageName):$(tag) will first look on disk before going out to Docker hub.

Github

    - name: Trivy Scan - Unknown, Low and Medium Severity
      uses: aquasecurity/trivy-action@0.0.7
      with:
        # image reference
        image-ref: ${{ env.ImageName }}:github-${{ env.BuildNumber }}  
        # exit code when vulnerabilities were found
        exit-code: 0
        # severities of vulnerabilities to be displayed
        severity: UNKNOWN,LOW,MEDIUM
        
    - name: Trivy Scan - High and Critical Severity
      uses: aquasecurity/trivy-action@0.0.7
      with:
        # image reference        
        image-ref: ${{ env.ImageName }}:github-${{ env.BuildNumber }}        
        # exit code when vulnerabilities were found
        exit-code: 1
        # severities of vulnerabilities to be displayed
        severity: HIGH,CRITICAL

To run with different filters, just like we do with Azure DevOps we need to run the command twice and set the severity filters and exit code for each. Just like with Azure DevOps and in fact, locally, it’ll look on disk first before going to Docker hub.

In this setup for both Azure DevOps and Github, if there are any vulnerabilities found that are classified as high or above we’ll fail the build.

When all is well, push to Docker hub

If we get this far, we’ve not found any vulnerabilities to stop the build - though there’s an argument to suggest that we should break the build for medium and higher vulnerabilities.

For this though, we’ll be happy with high and above still so let’s add in the task/action to push to Docker hub.

Azure DevOps

    - task: Docker@2
      inputs:
        containerRegistry: 'Docker hub'
        repository: '$(imageName)'
        command: 'push'
        tags: '$(tag)'

Here we’re just using the same Docker task and setting the command to push. We don’t need to build it again and just push the image up from on disk.

Github

The Github flavour of this action is the same action again which can be misleading because it looks like it will build the image once again.

    - name: Push Docker image
      uses: docker/build-push-action@v1.1.0
      with:
        # Username used to log in to a Docker registry. If not set then no login will occur
        username: ${{ secrets.DockerHubUser }}
        # Password or personal access token used to log in to a Docker registry. If not set then no login will occur
        password: ${{ secrets.DockerHubPassword }}
        # Docker repository to tag the image with
        repository: ${{ env.ImageName }}
        # Comma-delimited list of tags. These will be added to the registry/repository to form the image's tags
        tags: 'github-${{ env.BuildNumber }}'
        # Path to the build context
        path: 'src'
        # Path to the Dockerfile (Default is '{path}/Dockerfile')
        dockerfile: 'src/ContainerScanning.HelloWorld/Dockerfile'
        # Whether to push the image
        push: true

In fact, it is so misleading because the only difference between the build and push steps is push: true however, it doesn’t build the image twice - you can see a complete example of that at https://github.com/lgulliver/ContainerScanning/runs/859762381?check_suite_focus=true which is from when I was testing the Docker build and push actions before adding Trivy into the mix.

Done!

That’s it. Commit your respective YAML files and build away!

Leave a comment