Skip to content

Run security scans on Terraform and OpenTofu projects with Trivy and GitHub Actions

Published: | 4 min read

Table of contents

Open Table of contents

Intro

This is going to be a hands-on tutorial on how we can run security scans for our Terraform & OpenTofu projects with Trivy.

Prerequisites

Project setup

For this article, I will use a simplified Terraform project containing a configuration issue that triggers a vulnerability alert.

Project structure:

$ tree
.
├── README.md
├── apps
│   ├── alpha
│   │   ├── dev.tfvars
│   │   ├── main.tf
│   │   ├── providers.tf
│   │   └── variables.tf
│   └── common.tfvars
├── modules
└── trivy.yaml

We don’t actually need anything else than the main.tf:

resource "azurerm_resource_group" "main" {
  location = var.rg_location
  name     = var.rg_name
  tags     = var.rg_tags
}

# This will trigger a vulnerability alert
resource "azurerm_network_security_group" "failing_nsg" {
  name                = "tf-appsecuritygroup"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name

  security_rule {
    source_port_range          = "any"
    destination_port_ranges    = ["3389"]
    source_address_prefix      = "0.0.0.0/0"
    destination_address_prefix = "*"
  }
}

Let’s configure Trivy with a config file trivy.yaml:

timeout: 5m0s
exit-code: 1
# format: "sarif"
# output: "sarif-1.sarif"
# Report will be created in table format, and saved to app-specific directory
format: "table"
output: "./apps/alpha/trivy-report.txt"
report: "all"
misconfiguration:
  scanners:
    - terraform
  terraform:
    vars: ["./apps/alpha/dev.tfvars", "./apps/common.tfvars"]
scan:
  scanners:
  - secret
  - misconfig

Note that we configured to run secret and misconfig scanners, which are all that Trivy supports for IaC source code. Also, we defined an exit-code of 1 for failing checks.

The configuration also points Trivy scanners to the tfvars-files, which are used in the solution. I am not entirely sure, why Trivy does not pick these by default.

Run Trivy scan locally

With the HCL source and Trivy config in place, we can run the the Trivy scanners:

# On the project root:
trivy fs --config ./trivy.yaml ./

We run a filesystem scanner with the configuration above, for all directories in our project. If necessary, it is possible to exclude certain directories. It is also possible to filter the scan based on security issue severity.

Come as no surprise, a report file ./apps/alpha/trivy-report.txt was generated with the expected issues.

Now that we have a working configuration with a known security issue, let’s move on and create the GitHub Actions workflow.

Create a PR workflow for GitHub Actions

Let’s create a workflow, which will run the scan when a pull request is created targeting git branch main. Note: this workflow deliberately contains only the Trivy scan. In real projects, there are plenty of other things to do in this PR step.

Create new workflow file .github/workflows/pr-to-main.yaml with contents:

name: Validate Pull Request

on:
  pull_request:
    types: [opened, reopened]
    branches:
      - main

permissions:
  contents: read # Required for actions/checkout
  pull-requests: write # Required for gh bot to comment on PR

jobs:

  trivy-checks:
    runs-on: ubuntu-24.04
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Trivy Setup
        uses: aquasecurity/setup-trivy@v0.2.2
        with:
          cache: true
          version: v0.58.1

      - name: Run Trivy vulnerability scanner
        id: trivy
        uses: aquasecurity/trivy-action@0.29.0
        with:
          scan-type: 'fs' # filesystem scanner
          scan-ref: '.' # whole repo
          trivy-config: trivy.yaml # Use our config file
          skip-setup-trivy: true # We used the separate setup step to enable caching
        continue-on-error: true # By default, error would end the workflow

      - name: Report to PR
        uses: actions/github-script@v7
        if: github.event_name == 'pull_request'
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const fs = require('fs');
            const report = fs.readFileSync('./apps/alpha/trivy-report.txt', 'utf8');
            const output = `Trivy report:
            \`\`\`\n
            ${report.length > 0 ? report : 'No issues found'}
            \`\`\`

            *Pushed by: @${{ github.actor }}, Action: \`${{ github.event_name }}\`*`;

            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: output
            })
      - name: Trivy Status
        if: steps.trivy.outcome == 'failure'
        run: exit 1
        shell: bash

Points of interest in this workflow:

  1. We use a separate Trivy setup step to install a specific Trivy version, and cache that version for consecutive runs.
  2. The scanner is configured to use exit code of 1 (ERROR) for failing tests. Workflow is configured to continue regardless, so we can write the results to the PR.
  3. If the scan workflow step did result in failure because of security issues, we are failing the whole workflow run.

Run Trivy with GitHub Actions

To trigger this workflow, create a new git branch, for example dev, and make some changes to the code. Push to GitHub and create a pull request to branch main.

The workflow run will end in error, and you can read the reasons directly from the PR.

Fix the security issue in code, and open another PR. This time the workflow will run without an issue and code can me merged to main and the PR closed successfully.

Further reading