torrust-tracker-deployer 0.1.0

Torrust Tracker Deployer - Deployment Infrastructure with Ansible and OpenTofu
Documentation
name: Docker Security Scan

on:
  push:
    branches: [main, develop]
    paths:
      - "docker/**"
      - "templates/docker-compose/**"
      - "src/**"
      - ".github/workflows/docker-security-scan.yml"

  pull_request:
    paths:
      - "docker/**"
      - "templates/docker-compose/**"
      - "src/**"
      - ".github/workflows/docker-security-scan.yml"

  # Scheduled scans are important because new CVEs appear
  # even if the code or images didn't change
  schedule:
    - cron: "0 6 * * *" # Daily at 6 AM UTC

  workflow_dispatch:

jobs:
  scan-project-images:
    name: Scan Project-Built Docker Images
    runs-on: ubuntu-latest
    timeout-minutes: 15
    permissions:
      contents: read

    strategy:
      fail-fast: false
      matrix:
        image:
          - dockerfile: docker/deployer/Dockerfile
            context: .
            name: deployer
          - dockerfile: docker/provisioned-instance/Dockerfile
            context: docker/provisioned-instance
            name: provisioned-instance
          - dockerfile: docker/ssh-server/Dockerfile
            context: docker/ssh-server
            name: ssh-server
          - dockerfile: docker/backup/Dockerfile
            context: docker/backup
            name: tracker-backup

    steps:
      - name: Checkout code
        uses: actions/checkout@v5

      # Build images locally so Trivy scans exactly
      # what this repository produces
      - name: Build Docker image
        run: |
          docker build \
            -t torrust-tracker-deployer/${{ matrix.image.name }}:latest \
            -f ${{ matrix.image.dockerfile }} \
            ${{ matrix.image.context }}

      # Human-readable output in logs
      # This NEVER fails the job; it's only for visibility
      - name: Display vulnerabilities (table format)
        uses: aquasecurity/trivy-action@0.35.0
        with:
          image-ref: torrust-tracker-deployer/${{ matrix.image.name }}:latest
          format: "table"
          severity: "HIGH,CRITICAL"
          exit-code: "0"

      # SARIF generation for GitHub Code Scanning
      #
      # IMPORTANT:
      # - exit-code MUST be 0
      # - Trivy sometimes exits with 1 even when no vulns exist
      # - GitHub Security UI is responsible for enforcement
      - name: Generate SARIF (Code Scanning)
        uses: aquasecurity/trivy-action@0.35.0
        with:
          image-ref: torrust-tracker-deployer/${{ matrix.image.name }}:latest
          format: "sarif"
          output: "trivy-${{ matrix.image.name }}.sarif"
          severity: "HIGH,CRITICAL"
          exit-code: "0"
          scanners: "vuln"

      - name: Upload SARIF artifact
        uses: actions/upload-artifact@v6
        if: always()
        with:
          name: sarif-project-${{ matrix.image.name }}-${{ github.run_id }}
          path: trivy-${{ matrix.image.name }}.sarif
          retention-days: 30

  extract-images:
    name: Extract Third-Party Docker Images from Source
    runs-on: ubuntu-latest
    timeout-minutes: 10
    outputs:
      # JSON array of Docker image references for use in scan matrix
      # Example: ["torrust/tracker:develop","mysql:8.4","prom/prometheus:v3.11.2","grafana/grafana:13.0.0","caddy:2.11.2"]
      images: ${{ steps.extract.outputs.images }}

    steps:
      - name: Checkout code
        uses: actions/checkout@v5

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable

      - name: Cache Rust dependencies
        uses: Swatinem/rust-cache@v2

      - name: Build deployer CLI
        run: cargo build --release

      # Creates a minimal environment config with all optional services
      # enabled so that all third-party Docker images appear in the output.
      # - MySQL: enables mysql image in docker_images output
      # - Prometheus: enables prometheus image in docker_images output
      # - Grafana: enables grafana image in docker_images output
      # Uses fixture SSH keys (already committed to the repository).
      - name: Create environment config for image extraction
        run: |
          cat > /tmp/ci-images-env.json <<EOF
          {
            "environment": { "name": "ci-images" },
            "ssh_credentials": {
              "private_key_path": "$GITHUB_WORKSPACE/fixtures/testing_rsa",
              "public_key_path": "$GITHUB_WORKSPACE/fixtures/testing_rsa.pub"
            },
            "provider": {
              "provider": "lxd",
              "profile_name": "ci-profile"
            },
            "tracker": {
              "core": {
                "database": {
                  "driver": "mysql",
                  "host": "mysql",
                  "port": 3306,
                  "database_name": "torrust_tracker",
                  "username": "tracker_user",
                  "password": "tracker_password"
                },
                "private": false
              },
              "udp_trackers": [{ "bind_address": "0.0.0.0:6969" }],
              "http_trackers": [{ "bind_address": "0.0.0.0:7070" }],
              "http_api": { "bind_address": "0.0.0.0:1212", "admin_token": "ci-token" },
              "health_check_api": { "bind_address": "127.0.0.1:1313" }
            },
            "prometheus": { "scrape_interval_in_secs": 15 },
            "grafana": { "admin_user": "admin", "admin_password": "admin" }
          }
          EOF

      - name: Create minimal environment (no infrastructure provisioned)
        run: |
          ./target/release/torrust-tracker-deployer \
            --working-dir /tmp/ci-workspace \
            create environment \
            --env-file /tmp/ci-images-env.json

      # Extract Docker images from show command JSON output.
      # The show command lists all configured service images in docker_images.
      # Caddy is always in the docker-compose stack but is not tracked as
      # a domain service, so it is appended to the list manually.
      - name: Extract Docker images
        id: extract
        run: |
          show_output=$(./target/release/torrust-tracker-deployer \
            --working-dir /tmp/ci-workspace \
            show ci-images)

          images=$(echo "$show_output" | \
            jq -c '[
              .docker_images.tracker,
              .docker_images.mysql,
              .docker_images.prometheus,
              .docker_images.grafana
            ] | map(select(. != null)) + ["caddy:2.11.2"]')

          echo "Detected images: $images"
          echo "images=$images" >> "$GITHUB_OUTPUT"

  scan-third-party-images:
    name: Scan Third-Party Docker Images
    needs: extract-images
    runs-on: ubuntu-latest
    timeout-minutes: 15
    permissions:
      contents: read
      security-events: write

    strategy:
      fail-fast: false
      matrix:
        # Dynamic image list extracted from the deployer CLI at build time.
        # Images come from domain config constants — no manual maintenance needed.
        image: ${{ fromJson(needs.extract-images.outputs.images) }}

    steps:
      - name: Display vulnerabilities (table format)
        uses: aquasecurity/trivy-action@0.35.0
        with:
          image-ref: ${{ matrix.image }}
          format: "table"
          severity: "HIGH,CRITICAL"
          exit-code: "0"

      # Third-party images should NEVER block CI.
      # We only report findings to GitHub Security.
      - name: Generate SARIF (Code Scanning)
        uses: aquasecurity/trivy-action@0.35.0
        with:
          image-ref: ${{ matrix.image }}
          format: "sarif"
          output: "trivy.sarif"
          severity: "HIGH,CRITICAL"
          exit-code: "0"
          scanners: "vuln"

      # Needed to produce stable artifact names
      - name: Sanitize image name
        id: sanitize
        run: |
          echo "name=$(echo '${{ matrix.image }}' | tr '/:' '-')" >> "$GITHUB_OUTPUT"

      - name: Upload SARIF artifact
        uses: actions/upload-artifact@v6
        if: always()
        with:
          name: sarif-third-party-${{ steps.sanitize.outputs.name }}-${{ github.run_id }}
          path: trivy.sarif
          retention-days: 30

      # Use the supported CodeQL upload action so category tracking works
      # for dynamic third-party image configurations.
      - name: Upload third-party SARIF
        if: always()
        uses: github/codeql-action/upload-sarif@v4
        with:
          sarif_file: trivy.sarif
          category: docker-third-party-${{ steps.sanitize.outputs.name }}
        continue-on-error: true

  upload-sarif-results:
    name: Upload SARIF Results to GitHub Security
    runs-on: ubuntu-latest
    needs:
      - scan-project-images

    # Always run so we don't lose security visibility
    if: always()

    permissions:
      security-events: write

    steps:
      - name: Download all SARIF artifacts
        uses: actions/download-artifact@v7
        with:
          pattern: sarif-project-*-${{ github.run_id }}

      # Upload each SARIF file with CodeQL Action using unique categories.
      # The category parameter enables proper alert tracking per image.
      #
      # VIEWING RESULTS:
      # - For pull requests: /security/code-scanning?query=pr:NUMBER+is:open
      # - For branches: /security/code-scanning?query=is:open+branch:BRANCH-NAME
      # - For main branch: /security/code-scanning?query=is:open+branch:main (default view)
      # The default Security tab filters by "is:open branch:main" which only shows
      # alerts from the main branch, not from PR branches.
      - name: Upload project provisioned-instance SARIF
        if: always()
        uses: github/codeql-action/upload-sarif@v4
        with:
          sarif_file: sarif-project-provisioned-instance-${{ github.run_id }}/trivy-provisioned-instance.sarif
          category: docker-project-provisioned-instance
        continue-on-error: true

      - name: Upload project ssh-server SARIF
        if: always()
        uses: github/codeql-action/upload-sarif@v4
        with:
          sarif_file: sarif-project-ssh-server-${{ github.run_id }}/trivy-ssh-server.sarif
          category: docker-project-ssh-server
        continue-on-error: true