diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ecd9b7997..91e3b57e4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -552,12 +552,18 @@ jobs: exit 1 # containerize the package and upload to the GHCR upon new release (whether pre-release or not) - ghcr-build-and-push-on-release: + # Step 1: Build the Docker image and save as tar for scanning + ghcr-build-on-release: needs: deploy runs-on: ubuntu-latest permissions: contents: read packages: write + outputs: + image-tags: ${{ steps.set-tags.outputs.tags }} + image-name: synapsepythonclient-release + env: + TARFILE_NAME: synapsepythonclient-release.tar steps: - name: Check out the repo @@ -565,6 +571,62 @@ jobs: - name: Extract Release Version run: echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV shell: bash + - name: Set image tags + id: set-tags + shell: bash + run: | + if [[ "${{ github.event.release.prerelease }}" == "true" ]]; then + echo "tags=ghcr.io/sage-bionetworks/synapsepythonclient:${{ env.RELEASE_VERSION }}-prerelease" >> $GITHUB_OUTPUT + else + echo "tags=ghcr.io/sage-bionetworks/synapsepythonclient:latest,ghcr.io/sage-bionetworks/synapsepythonclient:${{ env.RELEASE_VERSION }}" >> $GITHUB_OUTPUT + fi + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Build Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: false + load: true + provenance: false + tags: synapsepythonclient-release:local + file: ./Dockerfile + platforms: linux/amd64 + cache-from: type=registry,ref=ghcr.io/sage-bionetworks/synapsepythonclient:build-cache + - name: Save Docker image to tar + run: docker save synapsepythonclient-release:local -o ${{ env.TARFILE_NAME }} + - name: Upload tar artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ env.TARFILE_NAME }} + path: ${{ env.TARFILE_NAME }} + retention-days: 1 + + # Step 2: Scan the built image with Trivy before pushing + trivy-scan-release: + needs: [ghcr-build-on-release] + uses: ./.github/workflows/trivy.yml + with: + SOURCE_TYPE: tar + TARFILE_NAME: synapsepythonclient-release.tar + IMAGE_NAME: synapsepythonclient-release:local + EXIT_CODE: 1 + permissions: + contents: read + security-events: write + actions: read + + # Step 3: Push the image to GHCR only if Trivy scan passes + ghcr-push-on-release: + needs: [ghcr-build-on-release, trivy-scan-release] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Check out the repo + uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 - name: Log in to GitHub Container Registry @@ -573,39 +635,74 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push Docker image (official release) - id: docker_build - if: '!github.event.release.prerelease' - uses: docker/build-push-action@v3 + - name: Build and push Docker image + uses: docker/build-push-action@v5 with: + context: . push: true provenance: false - tags: ghcr.io/sage-bionetworks/synapsepythonclient:latest,ghcr.io/sage-bionetworks/synapsepythonclient:${{ env.RELEASE_VERSION }} + tags: ${{ needs.ghcr-build-on-release.outputs.image-tags }} file: ./Dockerfile platforms: linux/amd64 cache-from: type=registry,ref=ghcr.io/sage-bionetworks/synapsepythonclient:build-cache cache-to: type=registry,mode=max,ref=ghcr.io/sage-bionetworks/synapsepythonclient:build-cache - - name: Build and push Docker image (pre-release) - id: docker_build_prerelease - if: 'github.event.release.prerelease' - uses: docker/build-push-action@v3 + + # containerize the package and upload to the GHCR upon commit in develop + # Step 1: Build the Docker image and save as tar for scanning + ghcr-build-on-develop: + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/develop' + permissions: + contents: read + packages: write + outputs: + image-tag: ghcr.io/sage-bionetworks/synapsepythonclient:develop-${{ github.sha }} + image-name: synapsepythonclient-develop + env: + TARFILE_NAME: synapsepythonclient-develop.tar + + steps: + - name: Check out the repo + uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Build Docker image + uses: docker/build-push-action@v5 with: - push: true + context: . + push: false + load: true provenance: false - tags: ghcr.io/sage-bionetworks/synapsepythonclient:${{ env.RELEASE_VERSION }}-prerelease + tags: synapsepythonclient-develop:local file: ./Dockerfile platforms: linux/amd64 - cache-from: type=registry,ref=ghcr.io/sage-bionetworks/synapsepythonclient:build-cache-prerelease - cache-to: type=registry,mode=max,ref=ghcr.io/sage-bionetworks/synapsepythonclient:build-cache-prerelease - - name: Output image digest (official release) - if: '!github.event.release.prerelease' - run: echo "The image digest for official release is ${{ steps.docker_build.outputs.digest }}" - - name: Output image digest (pre-release) - if: 'github.event.release.prerelease' - run: echo "The image digest for pre-release is ${{ steps.docker_build_prerelease.outputs.digest }}" + cache-from: type=registry,ref=ghcr.io/sage-bionetworks/synapsepythonclient:build-cache + - name: Save Docker image to tar + run: docker save synapsepythonclient-develop:local -o ${{ env.TARFILE_NAME }} + - name: Upload tar artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ env.TARFILE_NAME }} + path: ${{ env.TARFILE_NAME }} + retention-days: 1 + + # Step 2: Scan the built image with Trivy before pushing + trivy-scan-develop: + needs: [ghcr-build-on-develop] + uses: ./.github/workflows/trivy.yml + with: + SOURCE_TYPE: tar + TARFILE_NAME: synapsepythonclient-develop.tar + IMAGE_NAME: synapsepythonclient-develop:local + EXIT_CODE: 1 + permissions: + contents: read + security-events: write + actions: read - # containerize the package and upload to the GHCR upon commit in develop - ghcr-build-and-push-on-develop: + # Step 3: Push the image to GHCR only if Trivy scan passes + ghcr-push-on-develop: + needs: [ghcr-build-on-develop, trivy-scan-develop] runs-on: ubuntu-latest if: github.ref == 'refs/heads/develop' permissions: @@ -623,10 +720,10 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push Docker image for develop - id: docker_build + - name: Build and push Docker image uses: docker/build-push-action@v5 with: + context: . push: true provenance: false tags: ghcr.io/sage-bionetworks/synapsepythonclient:develop-${{ github.sha }} @@ -634,5 +731,3 @@ jobs: platforms: linux/amd64 cache-from: type=registry,ref=ghcr.io/sage-bionetworks/synapsepythonclient:build-cache cache-to: type=inline - - name: Output image digest - run: echo "The image digest is ${{ steps.docker_build.outputs.digest }}" diff --git a/.github/workflows/docker_build.yml b/.github/workflows/docker_build.yml new file mode 100644 index 000000000..1abc48ec3 --- /dev/null +++ b/.github/workflows/docker_build.yml @@ -0,0 +1,103 @@ +--- +# +# Reusable workflow to build, scan, and push a Docker image. +# Called by the periodic scan workflow to rebuild images +# when new vulnerabilities are found. +# +name: Build and publish a Docker image + +on: + workflow_call: + inputs: + REF_TO_CHECKOUT: + required: false + type: string + description: "Reference to checkout, e.g. a tag like v1.0.1. Defaults to the branch/tag of the current event." + IMAGE_REFERENCES: + required: true + type: string + description: "Comma-separated image references, e.g., ghcr.io/sage-bionetworks/synapsepythonclient:1.0.1" + +env: + TARFILE_NAME: image.tar + LOCAL_IMAGE_TAG: rebuild-image:local + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ inputs.REF_TO_CHECKOUT }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Build Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: false + load: true + tags: rebuild-image:local + file: ./Dockerfile + platforms: linux/amd64 + + - name: Save Docker image to tar + run: docker save rebuild-image:local -o ${{ env.TARFILE_NAME }} + + - name: Upload tarball for use by Trivy job + uses: actions/upload-artifact@v4 + with: + name: ${{ env.TARFILE_NAME }} + path: ${{ env.TARFILE_NAME }} + retention-days: 1 + + outputs: + tarfile_artifact: ${{ env.TARFILE_NAME }} + + trivy-scan: + needs: build + uses: "./.github/workflows/trivy.yml" + with: + SOURCE_TYPE: tar + IMAGE_NAME: rebuild-image:local + TARFILE_NAME: ${{ needs.build.outputs.tarfile_artifact }} + EXIT_CODE: 1 + permissions: + contents: read + security-events: write + actions: read + + push-image: + needs: [build, trivy-scan] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Download tar artifact + uses: actions/download-artifact@v4 + with: + name: ${{ needs.build.outputs.tarfile_artifact }} + path: /tmp + + - name: Load Docker image from tar + run: docker load -i /tmp/${{ needs.build.outputs.tarfile_artifact }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Tag and push Docker image + shell: bash + run: | + IFS=',' read -ra TAGS <<< "${{ inputs.IMAGE_REFERENCES }}" + for TAG in "${TAGS[@]}"; do + docker tag rebuild-image:local "$TAG" + docker push "$TAG" + done diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml new file mode 100644 index 000000000..9e2705f88 --- /dev/null +++ b/.github/workflows/trivy.yml @@ -0,0 +1,91 @@ +--- +# +# This workflow runs Trivy on a Docker image +# It can pull the image from a container registry +# or download a tar file. The latter is used +# to check a container image prior to publishing +# to the registry. + +name: Run Trivy on a Docker image and push results to GitHub + +on: + workflow_call: + inputs: + SOURCE_TYPE: # 'tar' or 'image' + required: true + type: string + TARFILE_NAME: # only used if SOURCE_TYPE=='tar' + required: false + type: string + IMAGE_NAME: + required: true + type: string + EXIT_CODE: # return code for failed scan. 0 means OK. Non-zero will fail the build when there are findings. + required: false + type: number + default: 0 + outputs: + trivy_conclusion: + description: "The pass/fail status from Trivy" + value: ${{ jobs.trivy.outputs.trivy_conclusion }} + +env: + sarif_file_name: trivy-results.sarif + # downloading the trivy-db from its default GitHub location fails because + # the site experiences too many downloads. The fix is to pull from this + # alternate location. + TRIVY_DB_REPOSITORY: public.ecr.aws/aquasecurity/trivy-db:2 + TRIVY_JAVA_DB_REPOSITORY: public.ecr.aws/aquasecurity/trivy-java-db:1 + +jobs: + trivy: + name: Trivy + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Download tar file + id: tar-download + uses: actions/download-artifact@v4 + if: ${{ inputs.SOURCE_TYPE == 'tar' }} + with: + name: ${{ inputs.TARFILE_NAME }} + path: /tmp + + - name: Load docker image from tar file + if: ${{ inputs.SOURCE_TYPE == 'tar' }} + run: docker load -i ${{ steps.tar-download.outputs.download-path }}/${{ inputs.TARFILE_NAME }} + + - name: Run Trivy vulnerability scanner for any major issues + uses: aquasecurity/trivy-action@0.32.0 + id: trivy + with: + image-ref: ${{ inputs.IMAGE_NAME }} + ignore-unfixed: true # skip vulnerabilities for which there is no fix + severity: 'CRITICAL,HIGH' + format: 'sarif' + limit-severities-for-sarif: true + output: ${{ env.sarif_file_name }} + exit-code: ${{ inputs.EXIT_CODE }} + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v3.25.12 + if: ${{ success() || steps.trivy.conclusion == 'failure' }} + with: + sarif_file: ${{ env.sarif_file_name }} + wait-for-processing: true + + - name: Upload Trivy output + uses: actions/upload-artifact@v4 + if: ${{ success() || steps.trivy.conclusion == 'failure' }} + with: + name: ${{ env.sarif_file_name }} + path: ${{ env.sarif_file_name }} + + outputs: + trivy_conclusion: ${{ steps.trivy.conclusion }} diff --git a/.github/workflows/trivy_periodic_scan.yml b/.github/workflows/trivy_periodic_scan.yml new file mode 100644 index 000000000..854f414f0 --- /dev/null +++ b/.github/workflows/trivy_periodic_scan.yml @@ -0,0 +1,89 @@ +--- +# +# This workflow scans the latest published container image +# for new vulnerabilities daily, publishing findings to +# the GitHub Security tab. If vulnerabilities are found, +# it bumps the patch version and triggers a rebuild. +# +name: Trivy Periodic Image Scan + +on: + schedule: + - cron: "0 0 * * *" # run daily + workflow_dispatch: {} + +jobs: + get-image-reference: + runs-on: ubuntu-latest + steps: + - name: Convert repo name to lower case + id: to_lower_case + run: | + # While GitHub repos can be mixed case, + # Docker images can only be lower case + repo_name=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]') + echo "repo_name=$repo_name" >> $GITHUB_ENV + - name: Find current version + id: find_version + uses: mathieudutour/github-tag-action@v6.2 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + dry_run: true # setting to 'true' means no new version is created + outputs: + image_repo: ghcr.io/${{ env.repo_name }} + image_tag: ${{ steps.find_version.outputs.previous_version }} + permissions: + contents: read + deployments: write + security-events: write + + periodic-scan: + needs: get-image-reference + uses: "./.github/workflows/trivy.yml" + with: + SOURCE_TYPE: image + IMAGE_NAME: ${{ needs.get-image-reference.outputs.image_repo }}:${{ needs.get-image-reference.outputs.image_tag }} + EXIT_CODE: 1 + permissions: + contents: read + deployments: write + security-events: write + + # If scan failed, bump tag and rebuild the image + bump-tag: + needs: periodic-scan + runs-on: ubuntu-latest + if: ${{ !cancelled() && needs.periodic-scan.outputs.trivy_conclusion == 'failure' }} + steps: + - name: Bump version and push tag + id: tag_version + uses: mathieudutour/github-tag-action@v6.2 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + - name: Parse new version + id: parsed + uses: booxmedialtd/ws-action-parse-semver@v1 + with: + input_string: ${{ steps.tag_version.outputs.new_version }} + outputs: + new_tag: ${{ steps.tag_version.outputs.new_tag }} + new_version: ${{ steps.tag_version.outputs.new_version }} + new_major_minor: ${{ steps.parsed.outputs.major }}.${{ steps.parsed.outputs.minor }} + permissions: + contents: write + deployments: write + security-events: write + + update-image: + needs: [get-image-reference, periodic-scan, bump-tag] + if: ${{ !cancelled() && needs.periodic-scan.outputs.trivy_conclusion == 'failure' }} + uses: "./.github/workflows/docker_build.yml" + with: + REF_TO_CHECKOUT: ${{ needs.bump-tag.outputs.new_tag }} + IMAGE_REFERENCES: "${{ needs.get-image-reference.outputs.image_repo }}:${{ needs.bump-tag.outputs.new_version }},\ + ${{ needs.get-image-reference.outputs.image_repo }}:${{ needs.bump-tag.outputs.new_major_minor }}" + permissions: + contents: read + deployments: write + security-events: write + packages: write