DEV Community

Josh Duffney
Josh Duffney

Posted on

Continuously patch GHCR images with Copacetic

Keep your GHCR-hosted images secure and updated with an effortless GitHub workflow. Follow along to see how!

Image description

⚙️ How It Works:
Here’s the gist of what we’re building:

  • Query: Fetch all your images from GHCR.
  • 🔍 Scan: Use Trivy to spot vulnerabilities.
  • 🔧 Patch: Apply fixes with Copacetic and update the images on GHCR.

Gathering Images with Glue Code

To patch all your images, we first need to know what’s in your GHCR. Both Trivy and Copacetic have GitHub Actions, making automation a breeze—we can loop through every image in a workflow.

🧐 The Challenge:

Dynamically generating the list of images and tags.

💡 The Solution:

A bit of Go "glue code" that hits GitHub’s REST API, grabs all your images and tags under your username, and outputs them to a matrix.json file. This file feeds into the workflow via the fromJson function, driving the patching process.

Image description

🔄 Continuous Patching Workflow

This is where the magic happens—a GitHub workflow that ties everything together. It starts by generating the image list, then loops through each one to scan, patch, and update it on GHCR.

The workflow splits into two jobs:

  • 1️⃣ Setup: Prepares the list of images.
  • 2️⃣ Patch: Runs the scanning, patching, and updating.

1️⃣ The Setup Job

The setup job kicks things off by creating the matrix of images to patch:

setup:
  runs-on: ubuntu-latest
  permissions:
    packages: read
  outputs:
    matrix: ${{ steps.set-matrix.outputs.matrix }}
  steps:
    - name: Checkout to repository
      uses: actions/checkout@v3
    - name: Generate image list
      run: ./listImages
    - name: Set matrix data
      id: set-matrix
      run: echo "matrix=$(jq -c . < ./matrix.json)" >> $GITHUB_OUTPUT
Enter fullscreen mode Exit fullscreen mode

🔹 What's Happening Here?

  • ✔️ Checks out your repo to access necessary files.
  • ✔️ Runs listImages, a Go program that builds matrix.json.
  • ✔️ Stores the matrix in a GITHUB_OUTPUT variable for the next job.

2️⃣ The Patch Job

The patch job takes the matrix and runs the full patching cycle for each image:

patch:
  runs-on: ubuntu-latest
  permissions:
    packages: read
    contents: write
  needs: setup
  strategy:
    fail-fast: false
    matrix:
      images: ${{ fromJson(needs.setup.outputs.matrix) }}
Enter fullscreen mode Exit fullscreen mode

🔹 Key Features

  • ✔️ Depends on setup (via needs: setup).
  • ✔️ Prevents failure from stopping all patches (fail-fast: false).
  • ✔️ Loads image list from matrix.json using fromJson().

🔍 Patch Step 1: Scan with Trivy

- name: Generate Trivy Report
  uses: aquasecurity/trivy-action@0.29.0
  with:
    scan-type: "image"
    format: "json"
    output: "report.json"
    ignore-unfixed: true
    vuln-type: "os"
    image-ref: ${{ env.REGISTRY}}/${{ matrix.images }}
Enter fullscreen mode Exit fullscreen mode
  • ✅ Scans the image for OS vulnerabilities and outputs a report.json file.

📊 Patch Step 2: Count Vulnerabilities

- name: Check vulnerability count
  id: vuln_count
  run: |
    report_file="report.json"
    vuln_count=$(jq 'if .Results then [.Results[] | select(.Class=="os-pkgs" and .Vulnerabilities!=null) | .Vulnerabilities[]] | length else 0 end' "$report_file")
    echo "vuln_count=$vuln_count" >> $GITHUB_OUTPUT
Enter fullscreen mode Exit fullscreen mode
  • ✅ Extracts the number of fixable vulnerabilities and saves the count as vuln_count.

🏷️ Patch Step 3: Create Patch Tag

- name: Create patch tag
  id: patch_tag
  run: |
    imageName=$(echo ${{ matrix.images }} | cut -d ':' -f1)
    current_tag=$(echo ${{ matrix.images }} | cut -d ':' -f2)
    if [[ $current_tag == *-[0-9] ]]; then
      numeric_tag=$(echo "$current_tag" | awk -F'-' '{print $NF}')
      non_numeric_tag=$(echo "$current_tag" | sed "s#-$numeric_tag##g")
      incremented_tag=$((numeric_tag+1))
      new_tag="$non_numeric_tag-$incremented_tag"
    else
      new_tag="$current_tag-1"
    fi
    echo "tag=$new_tag" >> $GITHUB_OUTPUT
    echo "imageName=$imageName" >> $GITHUB_OUTPUT
Enter fullscreen mode Exit fullscreen mode

🔧 Patch Step 4: Patch with Copacetic

- name: Run copa action
  if: steps.vuln_count.outputs.vuln_count != '0'
  id: copa
  uses: project-copacetic/copa-action@v1.2.1
  with:
    image: ${{ env.REGISTRY}}/${{ matrix.images }}
    image-report: "report.json"
    patched-tag: ${{ steps.patch_tag.outputs.tag }}
Enter fullscreen mode Exit fullscreen mode
  • ✅ Applies patches only if vulnerabilities exist.

📤 Patch Step 5: Push to GHCR

- name: Login to GHCR
  if: steps.copa.conclusion == 'success'
  id: login
  uses: docker/login-action@3.3.0
  with:
    registry: ghcr.io
    username: ${{ github.actor }}
    password: ${{ secrets.GHCR_TOKEN }}
- name: Push patched image
  if: steps.login.conclusion == 'success'
  run: |
    docker push ${{ steps.copa.outputs.patched-image }}
Enter fullscreen mode Exit fullscreen mode
  • ✅ Authenticates with GHCR and pushes the patched image if patching was successful.

Ready to try it?

Check the repo's README to set up your own continuous patching workflow! 🎉


Note: This solution only works for public registries, like GHCR. To use copa to patch locally, see Option 2 in the Copa docs. And for private see Option 1.

Top comments (0)