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
- A Terraform / OpenTofu project
- GitHub account
- Trivy installed locally (if you want to run scans locally)
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:
- We use a separate Trivy setup step to install a specific Trivy version, and cache that version for consecutive runs.
- 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. - 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.