diff --git a/.github/steps/trivy/action.yml b/.github/steps/trivy/action.yml new file mode 100644 index 00000000000..c37e9dda93b --- /dev/null +++ b/.github/steps/trivy/action.yml @@ -0,0 +1,134 @@ +name: "Vulnerability Scan" +description: "Trivy scan: repository dependencies and Dockerfile base images (code) or a built service image (image)" + +inputs: + scan: + description: "'code' or 'image'" + required: true + path: { default: "." } + skip-dirs: { default: "" } + skip-files: { default: "" } + image-name: { default: "" } + dockerfile: { default: "" } + context: { default: "." } + maven-token: { default: "${{ github.token }}" } + +runs: + using: "composite" + steps: + - name: Install Trivy + uses: aquasecurity/setup-trivy@v0.3.1 + with: + version: v0.74.0 + cache: true + + - name: Resolve Maven dependencies + if: inputs.scan == 'code' + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.maven-token }} + GITHUB_ACTOR: ${{ github.actor }} + SCAN_DIR: ${{ inputs.path }} + ACTION_PATH: ${{ github.action_path }} + run: | + set -euo pipefail + [ -f "$SCAN_DIR/pom.xml" ] || exit 0 + mvn_args=(-B -q -fn -DskipTests -gs "$ACTION_PATH/central-mirror.xml" -f "$SCAN_DIR/pom.xml") + [ -f .mvn/settings.xml ] && mvn_args+=(-s .mvn/settings.xml) + mvn "${mvn_args[@]}" dependency:go-offline | tee /tmp/mvn.log || true + ! grep -q '\[ERROR\]' /tmp/mvn.log || + echo "::warning::Some Maven dependencies could not be resolved; scan depth may be reduced" + + - name: Scan dependencies + if: inputs.scan == 'code' + shell: bash + env: + SCAN_DIR: ${{ inputs.path }} + SKIP_DIRS: ${{ inputs.skip-dirs }} + SKIP_FILES: ${{ inputs.skip-files }} + ACTION_PATH: ${{ github.action_path }} + run: | + set -euo pipefail + args=(--no-progress --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --offline-scan --format json) + [ -n "$SKIP_DIRS" ] && args+=(--skip-dirs "$SKIP_DIRS") + [ -n "$SKIP_FILES" ] && args+=(--skip-files "$SKIP_FILES") + report="trivy-${GITHUB_REPOSITORY##*/}-code-report.tsv" + trivy fs "${args[@]}" "$SCAN_DIR" | jq -r --arg src "" -f "$ACTION_PATH/to-tsv.jq" | sort -u > "$report" + while IFS=$'\t' read -r severity pkg installed fixed cve target; do + origin="-" + if [[ "$target" == *pom.xml ]]; then + grep -q "${pkg##*:}" "$SCAN_DIR/$target" 2>/dev/null && origin=direct || origin=transitive + fi + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$severity" "$pkg" "$installed" "$fixed" "$cve" "$target" "$origin" + done < "$report" > "$report.tmp" && mv "$report.tmp" "$report" + + - name: Scan Dockerfile base images + if: inputs.scan == 'code' + shell: bash + env: + SCAN_DIR: ${{ inputs.path }} + SKIP_DIRS: ${{ inputs.skip-dirs }} + ACTION_PATH: ${{ github.action_path }} + run: | + set -euo pipefail + find_args=(-name 'Dockerfile*' -not -path '*/.git/*' -not -path '*/node_modules/*') + for dir in ${SKIP_DIRS//,/ }; do find_args+=(-not -path "*/$dir/*" -not -path "$dir/*"); done + find "$SCAN_DIR" "${find_args[@]}" -print0 | + xargs -0 -r awk 'toupper($1)=="FROM" { img=$2; if (img ~ /^--/) img=$3; print img; if (toupper($3)=="AS") print "~"$4; if (toupper($4)=="AS") print "~"$5 }' | + sort -u > /tmp/base-images.txt + report="trivy-${GITHUB_REPOSITORY##*/}-docker-report.tsv" + : > "$report" + while read -r img; do + case "$img" in "~"*|scratch) continue ;; *'$'*) echo "::warning::Skipping base image with unresolved variable: $img"; continue ;; esac + grep -qxF "~$img" /tmp/base-images.txt && continue + trivy image --no-progress --scanners vuln --severity CRITICAL --ignore-unfixed --format json "$img" | + jq -r --arg src "$img" -f "$ACTION_PATH/to-tsv.jq" >> "$report" || + echo "::warning::Base image $img could not be scanned (pull/scan error); it was NOT checked" + done < /tmp/base-images.txt + sort -u "$report" -o "$report" + find . -maxdepth 1 -name 'trivy-*.tsv' -empty -delete + + - name: Scan built image + if: inputs.scan == 'image' + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.maven-token }} + GITHUB_ACTOR: ${{ github.actor }} + IMAGE_NAME: ${{ inputs.image-name }} + ACTION_PATH: ${{ github.action_path }} + run: | + set -euo pipefail + docker buildx build --platform linux/amd64 --secret id=GITHUB_TOKEN,env=GITHUB_TOKEN --build-arg GITHUB_ACTOR="$GITHUB_ACTOR" \ + -f "${{ inputs.dockerfile }}" --output type=docker,dest=/tmp/image.tar "${{ inputs.context }}" + trivy image --no-progress --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --pkg-types os --format json --input /tmp/image.tar | + jq -r --arg src "" -f "$ACTION_PATH/to-tsv.jq" | sort -u > "trivy-${GITHUB_REPOSITORY##*/}-image-report-${IMAGE_NAME}.tsv" + find . -maxdepth 1 -name 'trivy-*.tsv' -empty -delete + + - name: Upload report + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.scan == 'code' && (inputs.image-name && format('scan_code-{0}', inputs.image-name) || 'scan_code') || format('scan_image-{0}', inputs.image-name) }} + path: trivy-*.tsv + if-no-files-found: ignore + + - name: Evaluate + shell: bash + env: + ARTIFACT: ${{ inputs.scan == 'code' && 'scan_code' || 'scan_image' }} + GH_TOKEN: ${{ github.token }} + STATUS_CONTEXT: "${{ inputs.scan == 'image' && format('Scan Image: {0}', inputs.image-name) || '' }}" + STATUS_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + report_status() { + [ -n "$STATUS_CONTEXT" ] && [ -n "$STATUS_SHA" ] || return 0 + gh api "repos/$GITHUB_REPOSITORY/statuses/$STATUS_SHA" -f state="$1" -f context="$STATUS_CONTEXT" -f description="$2" \ + -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" > /dev/null || + echo "::warning::Could not publish the $STATUS_CONTEXT commit status" + } + files=$(ls trivy-*.tsv 2>/dev/null) || { echo "No vulnerabilities found."; report_status success "No HIGH/CRITICAL vulnerabilities found"; exit 0; } + column -t -s "$(printf '\t')" $files + unique=$(cut -f1,2,5 $files | sort -u | wc -l | tr -d ' ') + report_status failure "$unique HIGH/CRITICAL vulnerabilities — full report in the $ARTIFACT artifact" + echo "::error::Trivy found $unique unique HIGH/CRITICAL vulnerabilities ($(cat $files | wc -l | tr -d ' ') occurrences across modules) — full report in the ${ARTIFACT} artifact" + exit 1 diff --git a/.github/steps/trivy/central-mirror.xml b/.github/steps/trivy/central-mirror.xml new file mode 100644 index 00000000000..b76a9369bfb --- /dev/null +++ b/.github/steps/trivy/central-mirror.xml @@ -0,0 +1,9 @@ + + + + google-central + https://maven-central.storage-download.googleapis.com/maven2/ + central + + + diff --git a/.github/steps/trivy/to-tsv.jq b/.github/steps/trivy/to-tsv.jq new file mode 100644 index 00000000000..083634fad1d --- /dev/null +++ b/.github/steps/trivy/to-tsv.jq @@ -0,0 +1,5 @@ +.Results[]? as $r +| $r.Vulnerabilities[]? +| [.Severity, .PkgName, .InstalledVersion, (.FixedVersion // "-"), .VulnerabilityID, + (if $src == "" then $r.Target else $src end)] +| @tsv diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aebbcda184f..94a413188c5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,6 +34,26 @@ jobs: github.event_name == 'pull_request' && !github.event.pull_request.draft + scan: + name: "Scan Code" + runs-on: ubuntu-latest + needs: [changes] + permissions: + contents: read + packages: read + if: github.event_name == 'pull_request' + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: Scan code + uses: ./.github/steps/trivy + with: + scan: code + test_client: name: "Test Client (${{ matrix.name }})" needs: [changes] @@ -140,7 +160,7 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - + - name: Install GoReleaser uses: goreleaser/goreleaser-action@v6 with: diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 00000000000..60d72f61302 --- /dev/null +++ b/.trivyignore @@ -0,0 +1,3 @@ +# Vulnerabilities to skip in the Trivy scan: one CVE/GHSA id per line, e.g.: +# +# CVE-2026-12345