Skip to content

Security scan

Security scan #1647

Workflow file for this run

---
name: Security scan
# Periodically scan production images for security vulnerabilities
on:
schedule:
# Once a day at midnight
- cron: '0 0 * * *'
# Once an hour
# - cron: '0 * * * *'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
# Name of image
IMAGE_NAME: foo-app
# Name of org in GHCR Docker repository (must be lowercase)
IMAGE_OWNER: ${{ github.repository_owner }}
# IMAGE_OWNER: cogini
# AWS ECR Docker repo "org" name (may be blank, otherwise must have trailing slash)
ECR_IMAGE_OWNER: cogini/
# ECR_IMAGE_OWNER: ''
# Tag for release images, used to find the latest deployed image.
IMAGE_TAG: latest
IMAGE_VER: ${{ github.sha }}
# Registry to pull internal images from
REGISTRY: ghcr.io/
# Registry for public images, default (blank) is docker.io
# PUBLIC_REGISTRY: ''
# Assume that base image has been synced to local registry
PUBLIC_REGISTRY: 'ghcr.io/'
AWS_OTEL_COLLECTOR_REPO_ORG: ${{ github.repository_owner }}
POSTGRES_REPO_ORG: ${{ github.repository_owner }}
RABBITMQ_REPO_ORG: ${{ github.repository_owner }}
# Git "main" branch. This might be "master" for old repos
MAIN_BRANCH: main
# GitHub Environment secrets and variables
# Docker Hub credentials to pull base images without rate limits
# secrets.DOCKERHUB_USERNAME
# secrets.DOCKERHUB_TOKEN
# AWS Account
# secrets.AWS_ACCOUNT_ID
# AWS default region
# vars.AWS_REGION: us-east-1
# AWS role allowing GitHub Actions to access resources and deploy
# secrets.AWS_ROLE_TO_ASSUME: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/foo-${{ environment }}-github-action
# S3 bucket where assets are deployed, e.g., for use with CloudFront CDN
# vars.S3_BUCKET_ASSETS: cogini-foo-app-dev-app-assets
# S3 bucket with data for testing
# vars.S3_BUCKET_CI: cogini-prod-foo-ci
# SSH key to access private repos during build
# secrets.SSH_PRIVATE_KEY
# GitHub access token to access other repositories during build
# secrets.DEVOPS_ACCESS_TOKEN
# AWS ECS deployment role names to put in task definition
# secrets.TASK_ROLE_ARN: "arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/foo-app"
# secrets.EXECUTION_ROLE_ARN: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/foo-ecs-task-execution-role
# AWS Systems Manager Parameter Store prefix for config keys
# vars.AWS_PS_PREFIX: cogini/foo/prod
# AWS CloudFront distribution ID to invalidate cache after deploy
# secrets.CLOUDFRONT_CDN_DISTRIBUTION_ID
# AppSignal API key for error reporting
# secrets.APPSIGNAL_PUSH_API_KEY
# DataDog API key for reporting test results
# secrets.ACTIONS_DD_API_KEY
# secrets.DD_API_KEY
# Oban Pro license
# secrets.OBAN_KEY_FINGERPRINT
# secrets.OBAN_LICENSE_KEY
# Sentry.io
# secrets.SENTRY_AUTH_TOKEN
# secrets.SENTRY_ORG
# secrets.SENTRY_PROJECT
# secrets.GITLEAKS_LICENSE
# secrets.SNYK_TOKEN
# Target port for prod image tests
APP_PORT: 4000
# Elixir module, used in health checks
ELIXIR_MODULE: PhoenixContainerExample
# Elixir module Erlang app name
ELIXIR_APP: phoenix_container_example
# AWS SSM Parameter Store name prefix
# AWS_PS_PREFIX: cogini/foo/dev
# APPSIGNAL_APP_NAME: bounce
# Name of environment for resources created by Terraform
# TERRAFORM_ENV: dev
# secrets.TASK_ROLE_ARN: "arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/foo-app"
# secrets.EXECUTION_ROLE_ARN: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/foo-ecs-task-execution-role
# Docker
DOCKER_BUILDKIT: '1'
COMPOSE_DOCKER_CLI_BUILD: '1'
COMPOSE_FILE: docker-compose.gha.yml
DOCKER_FILE: deploy/debian.Dockerfile
jobs:
config:
name: Configure build
runs-on: ubuntu-latest
outputs:
# Combinations of Elixir/Erlang/OS to run internal tests on
# Generally this will match prod
test-matrix: ${{ steps.common-matrix.outputs.result }}
# Combinations to tests on in parallel, i.e., mix test with partitions
test-matrix-parallel: ${{ steps.test-matrix-parallel.outputs.result }}
# Combinations for prod images
# Used for the final deploy image as well as external tests
prod-matrix: ${{ steps.prod-matrix.outputs.result }}
# Combinations to deploy (to prod or another environment)
# Usually only a single variant
deploy-matrix: ${{ steps.deploy-matrix.outputs.result }}
# Combinations used to build assets
# Usually only a single variant
assets-matrix: ${{ steps.assets-matrix.outputs.result }}
# Combinations to sync images to GHCR for
sync-matrix: ${{ steps.common-matrix.outputs.result }}
# Select environment to deploy to based on git branch/tag
environment: ${{ (github.ref_name == 'main' && 'staging') || (github.ref_name == 'prod' && 'production') }}
# Whether to deploy. Only standard tags/branches deploy, not other dev branches.
# deploy: ${{ contains(fromJson('["main", "staging", "qa", "prod"]'), github.ref_name) }}
deploy: '1'
# Sync base and 3rd-party images to GHCR
# sync-images: '${{ steps.get-sync-images.outputs.result }}'
sync-images: '1'
# Sync 3rd-party images to ECR
# sync-images-ecr: '${{ steps.get-sync-images.outputs.result }}'
sync-images-ecr: '0'
# Tag for GHCR images. The repo is shared between all environments, so use tags to distinguish.
image-tag: ${{ (github.ref_name == 'main' && 'test') || (github.ref_name == 'qa' && 'qa') || (github.ref_name == 'prod' && 'latest') || github.ref_name }}
# Tag for ECR images. Repos are separate between environments, so each gets "latest" tag
image-tag-ecr: ${{ contains(fromJson('["main", "staging", "prod"]'), github.ref_name) && 'latest' || github.ref_name }}
image-tag-deploy: ${{ steps.get-image-tag-deploy.outputs.result }}
# Enable AWS interactions in the build, including deploys.
aws-enabled: '1'
# Run unit tests
test: '1'
# Run dialyzer
test-dialyzer: '0'
# Security scan prod image
scan-prod-image: '0'
# Security scan code image
scan-code: '0'
# GitHub Advanced Security, free for open source, otherwise a paid feature
# https://docs.github.com/en/get-started/learning-about-github/about-github-advanced-security
# https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/sarif-support-for-code-scanning
# https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/uploading-a-sarif-file-to-github
github-advanced-security: '0'
# Upload assets to S3
upload-assets: '0'
# Create GitHub release for this build
create-release: '0'
# Deploy using AWS CodeDeploy
deploy-codedeploy: '0'
# Send results to Datadog
datadog: '0'
# Notify AppSignal of new app revision
notify-appsignal: '1'
# Notify Sentry of new app release
notify-sentry: '0'
steps:
- name: Configure common-matrix
id: common-matrix
uses: actions/github-script@v8
# Specify versions of Erlang, Elixir, and base OS
# in a combination supported by https://hub.docker.com/r/hexpm/elixir/tags
# {
# os: "alma",
# elixir: "1.18.4",
# otp: "28.1",
# build_os_ver: "8",
# prod_os_ver: "8"
# },
# {
# os: "centos7",
# elixir: "1.18.4",
# otp: "28.1",
# build_os_ver: "7.9.2009",
# prod_os_ver: "7.9.2009"
# },
with:
script: |
return {
include: [
{
os: "debian",
elixir: "1.19.5",
otp: "28.5",
build_os_ver: "trixie-20260421-slim",
prod_os_ver: "trixie-20260421-slim"
},
]
}
- name: Configure test-matrix-parallel
id: test-matrix-parallel
uses: actions/github-script@v8
with:
script: |
let platforms = [
{
os: "debian",
elixir: "1.19.5",
otp: "28.5",
build_os_ver: "trixie-20260421-slim",
prod_os_ver: "trixie-20260421-slim"
},
]
let ci_nodes = [1, 2]
let ci_node_total = ci_nodes.length
let matrix = []
for (let i = 0; i < platforms.length; i++) {
for (let j = 0; j < ci_nodes.length; j++) {
matrix.push(Object.assign({}, platforms[i], {ci_node_total: ci_node_total, ci_node_index: ci_nodes[j]}) )
}
}
return { include: matrix }
- name: Configure prod-matrix
id: prod-matrix
uses: actions/github-script@v8
# {
# os: "alma",
# elixir: "1.18.4",
# otp: "28.1",
# build_os_ver: "8",
# prod_os_ver: "8"
# },
# {
# os: "centos7",
# elixir: "1.18.4",
# otp: "28.1",
# build_os_ver: "7",
# prod_os_ver: "7"
# },
with:
script: |
return {
include: [
{
os: "debian",
elixir: "1.19.5",
otp: "28.5",
build_os_ver: "trixie-20260421-slim",
prod_os_ver: "trixie-20260421-slim"
},
]
}
- name: Configure deploy-matrix
id: deploy-matrix
uses: actions/github-script@v8
with:
script: |
return {
include: [
{
os: "debian",
elixir: "1.19.5",
otp: "28.5",
build_os_ver: "trixie-20260421-slim",
prod_os_ver: "trixie-20260421-slim"
},
]
}
- name: Configure assets-matrix
uses: actions/github-script@v8
id: assets-matrix
with:
script: |
return {
include: [
{
os: "debian",
elixir: "1.19.5",
otp: "28.5",
build_os_ver: "trixie-20260421-slim",
prod_os_ver: "trixie-20260421-slim"
},
]
}
# Select GitHub Actions environment based on branch or tag name
# This also implicitly selects the AWS deploy environment.
# * `main` branch or `test` tag deploys to test
# * `staging` or `staging-*` tag/branch deploys to staging
# * `qa` or `qa-*` tag/branch deploys to qa
# * `prod` tag deploys to prod and dr
- name: Configure environment
id: get-environment
uses: actions/github-script@v8
env:
GITHUB_REF_NAME: ${{ github.ref_name }}
with:
result-encoding: string
script: |
ref_name = process.env.GITHUB_REF_NAME
var environments = {
'test': 'test',
'main': 'test',
'staging': 'staging',
'qa': 'qa',
'prod': 'prod',
'dr': 'prod',
'media': 'prod',
}
if (ref_name in environments) {
result = environments[ref_name]
} else {
if (ref_name.startsWith('staging-')) {
result = 'staging'
} else if (ref_name.startsWith('qa-')) {
result = 'qa'
} else {
result = 'test'
}
}
return result
# Get image tag for deploy based on environment
# In test, staging, and qa, deploy is based on GitHub SHA
# In prod, deploy is based on staging ECR repo
# In dr, deploy is based on prod ECR repo in DR region
- name: Configure image-tag-deploy
id: get-image-tag-deploy
uses: actions/github-script@v8
env:
GITHUB_REF_NAME: ${{ github.ref_name }}
with:
result-encoding: string
script: |
ref_name = process.env.GITHUB_REF_NAME
if (ref_name == 'prod' || ref_name == 'dr') {
result = 'prod'
} else {
result = context.sha
}
return result
# Determine whether to deploy to AWS based on branch or tag name
- name: Configure deploy
id: get-deploy
uses: actions/github-script@v8
env:
GITHUB_REF_NAME: ${{ github.ref_name }}
with:
result-encoding: string
script: |
ref_name = process.env.GITHUB_REF_NAME
if (ref_name == 'main' || ref_name == 'staging' || ref_name.startsWith('staging-')) {
result = '1'
} else if (ref_name == 'qa' || ref_name.startsWith('qa-')) {
result = '1'
} else if (ref_name == 'testing' || ref_name.startsWith('devops-')) {
result = '1'
} else {
result = '0'
}
return result
- name: Configure sync-images
id: get-sync-images
uses: actions/github-script@v8
env:
GITHUB_REF_NAME: ${{ github.ref_name }}
with:
result-encoding: string
script: |
ref_name = process.env.GITHUB_REF_NAME
if (ref_name == 'main' || ref_name == 'staging' || ref_name.startsWith('staging-')) {
result = '1'
} else if (ref_name == 'testing' || ref_name.startsWith('devops-')) {
result = '1'
} else if (ref_name == 'qa' || ref_name.startsWith('qa-')) {
result = '1'
} else {
result = '0'
}
return result
scan:
name: Security scan prod image
if: needs.config.outputs.scan-prod-image == '1'
needs: [config]
permissions:
id-token: write
contents: read
packages: read
checks: write
pull-requests: write
issues: read
# Upload SARIF report files
security-events: write
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.config.outputs.prod-matrix) }}
env:
DOCKER_FILE: deploy/${{ matrix.os }}.Dockerfile
VAR: ${{ matrix.elixir }}-erlang-${{ matrix.otp }}-${{ matrix.os }}-${{ matrix.build_os_ver }}
GITHUB_ADVANCED_SECURITY: ${{ needs.config.outputs.github-advanced-security }}
steps:
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pull image
run: docker pull "ghcr.io/${IMAGE_OWNER}/${IMAGE_NAME}:${VAR}${IMAGE_VER}"
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@0.35.0
# https://github.com/aquasecurity/trivy-action
# https://github.com/marketplace/actions/aqua-security-trivy#inputs
with:
image-ref: ghcr.io/${{ env.IMAGE_OWNER }}/${{ env.IMAGE_NAME }}:${{ env.VAR }}${{ env.IMAGE_VER }}
# exit-code: '1' # fail build
# ignore-unfixed: true
# vuln-type: 'os,library'
# severity: 'CRITICAL,HIGH'
# cache-dir: /var/cache
format: ${{ env.GITHUB_ADVANCED_SECURITY == '1' && 'sarif' || 'table' }}
# output: ${{ env.GITHUB_ADVANCED_SECURITY == '1' && 'trivy-results.sarif' }}
- name: Display scan results
if: ${{ always() && env.GITHUB_ADVANCED_SECURITY == '1' }}
run: cat trivy-results.sarif | jq .
- name: Upload Trivy scan results to GitHub Security tab
if: ${{ always() && env.GITHUB_ADVANCED_SECURITY == '1' }}
uses: github/codeql-action/upload-sarif@v3
# Requires GitHub Advanced Security
# https://docs.github.com/en/get-started/learning-about-github/about-github-advanced-security
# https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/sarif-support-for-code-scanning
# https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/uploading-a-sarif-file-to-github
with:
sarif_file: 'trivy-results.sarif'
category: trivy
- name: Scan image with Grype
uses: anchore/scan-action@v7
# https://github.com/marketplace/actions/anchore-container-scan
id: scan-grype
with:
image: ghcr.io/${{ env.IMAGE_OWNER }}/${{ env.IMAGE_NAME }}:${{ env.VAR }}${{ env.IMAGE_VER }}
# severity-cutoff: critical
fail-build: false
# output-format: 'sarif'
output-format: table
# output-format: ${{ env.GITHUB_ADVANCED_SECURITY == '1' && 'sarif' || 'table' }}
- name: Display scan results
if: ${{ always() && env.GITHUB_ADVANCED_SECURITY == '1' }}
run: cat ${{ steps.scan-grype.outputs.sarif }} | jq .
- name: Upload Grype scan results to GitHub Security tab
if: ${{ always() && env.GITHUB_ADVANCED_SECURITY == '1' }}
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: ${{ steps.scan-grype.outputs.sarif }}
category: grype
# - name: Scan image with Snyk
# uses: snyk/actions/docker@master
# continue-on-error: true
# env:
# SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
# with:
# command: test
# image: ghcr.io/${{ env.IMAGE_OWNER }}/${{ env.IMAGE_NAME }}:${{ env.VAR }}${{ env.IMAGE_VER }}
# args: --file=${{ env.DOCKER_FILE }} --project-name=api