diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ecd9b7997..67eeb0cd5 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,74 +571,167 @@ 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: Log in to GitHub Container Registry - uses: docker/login-action@v2 - with: - 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 Docker image + uses: docker/build-push-action@v5 with: - push: true + context: . + push: false + load: true provenance: false - tags: ghcr.io/sage-bionetworks/synapsepythonclient:latest,ghcr.io/sage-bionetworks/synapsepythonclient:${{ env.RELEASE_VERSION }} + tags: synapsepythonclient-release:local 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 + cache-to: type=inline + - 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: - push: true - provenance: false - tags: ghcr.io/sage-bionetworks/synapsepythonclient:${{ env.RELEASE_VERSION }}-prerelease - 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 }}" + 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 - # 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-release: + needs: [ghcr-build-on-release, trivy-scan-release] runs-on: ubuntu-latest - if: github.ref == 'refs/heads/develop' permissions: contents: read packages: write + env: + TARFILE_NAME: synapsepythonclient-release.tar + steps: - - name: Check out the repo - uses: actions/checkout@v4 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + - name: Download scanned tar + uses: actions/download-artifact@v4 + with: + name: ${{ env.TARFILE_NAME }} + path: /tmp + - name: Load Docker image from tar + run: docker load -i /tmp/${{ env.TARFILE_NAME }} - name: Log in to GitHub Container Registry uses: docker/login-action@v2 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push Docker image for develop - id: docker_build + - name: Tag and push Docker image + shell: bash + run: | + IFS=',' read -ra TAGS <<< "${{ needs.ghcr-build-on-release.outputs.image-tags }}" + for TAG in "${TAGS[@]}"; do + docker tag synapsepythonclient-release:local "$TAG" + docker push "$TAG" + done + + # 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:develop-${{ github.sha }} + tags: synapsepythonclient-develop:local file: ./Dockerfile 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 }}" + - 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 + + # 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: + contents: read + packages: write + + env: + TARFILE_NAME: synapsepythonclient-develop.tar + + steps: + - name: Download scanned tar + uses: actions/download-artifact@v4 + with: + name: ${{ env.TARFILE_NAME }} + path: /tmp + - name: Load Docker image from tar + run: docker load -i /tmp/${{ env.TARFILE_NAME }} + - name: Log in 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 + run: | + docker tag synapsepythonclient-develop:local "${{ needs.ghcr-build-on-develop.outputs.image-tag }}" + docker push "${{ needs.ghcr-build-on-develop.outputs.image-tag }}" diff --git a/.github/workflows/docker_build.yml b/.github/workflows/docker_build.yml new file mode 100644 index 000000000..7be579a76 --- /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: ${{ env.LOCAL_IMAGE_TAG }} + file: ./Dockerfile + platforms: linux/amd64 + + - name: Save Docker image to tar + run: docker save ${{ env.LOCAL_IMAGE_TAG }} -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 ${{ env.LOCAL_IMAGE_TAG }} "$TAG" + docker push "$TAG" + done diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml new file mode 100644 index 000000000..a8dcdfb4d --- /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@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.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@5c8a8a642e79153f5d047b10ec1cba1d1cc65699 # v3.35.1 + if: ${{ !cancelled() }} + with: + sarif_file: ${{ env.sarif_file_name }} + wait-for-processing: true + + - name: Upload Trivy output + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + 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..1014e528a --- /dev/null +++ b/.github/workflows/trivy_periodic_scan.yml @@ -0,0 +1,155 @@ +--- +# +# 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_OUTPUT + - name: Find current version + id: find_version + uses: mathieudutour/github-tag-action@a22cf08638b34d5badda920f9daf6e72c477b07b # 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/${{ steps.to_lower_case.outputs.repo_name }} + image_tag: ${{ steps.find_version.outputs.previous_tag }} + permissions: + contents: read + + 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, compute next version (dry run) and attempt a rebuild. + # The tag is only created after a successful rebuild to prevent + # infinite bump loops when a CVE fix requires a major version upgrade + # that a simple rebuild cannot provide. + compute-next-version: + needs: periodic-scan + runs-on: ubuntu-latest + if: ${{ !cancelled() && needs.periodic-scan.outputs.trivy_conclusion == 'failure' }} + steps: + - name: Compute next version (dry run — no tag created) + id: tag_version + uses: mathieudutour/github-tag-action@a22cf08638b34d5badda920f9daf6e72c477b07b # v6.2 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + dry_run: true + - name: Parse new version + id: parsed + uses: booxmedialtd/ws-action-parse-semver@7784200024d6b3fc01253e617ec0168daf603de3 # v1.4.7 + 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: read + + update-image: + needs: [get-image-reference, periodic-scan, compute-next-version] + if: ${{ !cancelled() && needs.periodic-scan.outputs.trivy_conclusion == 'failure' && needs.compute-next-version.result == 'success' }} + uses: "./.github/workflows/docker_build.yml" + with: + REF_TO_CHECKOUT: ${{ needs.get-image-reference.outputs.image_tag }} + IMAGE_REFERENCES: "${{ needs.get-image-reference.outputs.image_repo }}:${{ needs.compute-next-version.outputs.new_tag }},${{ needs.get-image-reference.outputs.image_repo }}:${{ needs.compute-next-version.outputs.new_major_minor }}" + permissions: + contents: read + deployments: write + security-events: write + packages: write + + # Only create the git tag after the rebuilt image passes Trivy and is pushed + create-tag: + needs: [periodic-scan, update-image] + runs-on: ubuntu-latest + if: ${{ !cancelled() && needs.periodic-scan.outputs.trivy_conclusion == 'failure' && needs.update-image.result == 'success' }} + steps: + - name: Bump version and push tag + uses: mathieudutour/github-tag-action@a22cf08638b34d5badda920f9daf6e72c477b07b # v6.2 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + permissions: + contents: write + + # If the rebuild still has vulnerabilities, open a GitHub issue for manual triage + # instead of looping endlessly + alert-on-failure: + needs: [periodic-scan, update-image] + runs-on: ubuntu-latest + if: ${{ !cancelled() && needs.periodic-scan.outputs.trivy_conclusion == 'failure' && needs.update-image.result == 'failure' }} + permissions: + issues: write + steps: + - name: Check for existing open issue + id: check_issue + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const issues = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'trivy,security', + per_page: 1 + }); + return issues.data.length > 0 ? 'true' : 'false'; + result-encoding: string + + - name: Create GitHub issue for unresolved vulnerabilities + if: steps.check_issue.outputs.result == 'false' + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: 'Trivy: unresolved container vulnerabilities after rebuild', + body: [ + '## Summary', + '', + 'The daily Trivy periodic scan found Critical/High vulnerabilities in the latest published Docker image.', + 'An automated rebuild was attempted but the rebuilt image **still has vulnerabilities**,', + 'indicating the fix requires a manual dependency update rather than a base image refresh.', + '', + '## Next steps', + '', + `- Review findings in the [Security tab](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/security/code-scanning)`, + '- Update the affected dependencies to a version that includes the fix', + '- Or add the CVE ID(s) to a `.trivyignore` file if the risk is accepted', + '', + '## Details', + '', + `- **Workflow run:** ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, + `- **Triggered at:** ${new Date().toISOString()}`, + ].join('\n'), + labels: ['trivy', 'security'] + });