From 24c27c62161b282aeeb60e7be55d1d3530f58b09 Mon Sep 17 00:00:00 2001 From: Carlos Ruiz Lantero Date: Fri, 3 Oct 2025 16:48:49 +0200 Subject: [PATCH] Add boilerplate --- .github/workflows/test-scan-image.yaml | 60 +++++++ actions/scan-image/CHANGELOG.md | 8 + actions/scan-image/README.md | 26 +++ actions/scan-image/action.yaml | 209 +++++++++++++++++++++++++ 4 files changed, 303 insertions(+) create mode 100644 .github/workflows/test-scan-image.yaml create mode 100644 actions/scan-image/CHANGELOG.md create mode 100644 actions/scan-image/README.md create mode 100644 actions/scan-image/action.yaml diff --git a/.github/workflows/test-scan-image.yaml b/.github/workflows/test-scan-image.yaml new file mode 100644 index 000000000..ba68e36a3 --- /dev/null +++ b/.github/workflows/test-scan-image.yaml @@ -0,0 +1,60 @@ +name: Test scan-image action + +on: + push: + branches: + - main + paths: + - "actions/scan-image/**" + - ".github/workflows/test-scan-image.yaml" + + pull_request: + paths: + - "actions/scan-image/**" + - ".github/workflows/test-scan-image.yaml" + types: + - edited + - opened + - ready_for_review + - synchronize + + merge_group: + +permissions: + id-token: write + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + + - name: (Test 1) Scan public image without vulnerabilities + id: scan-image-1 + uses: Lantero/shared-workflows/actions/scan-image@1c42928e7de3547403e934df7fa59a3e59865a75 # scan-action/v0.1.0 + with: + image_name: docker.io/hello-world + image_source: public + + - name: (Test 2) Scan public image with some vulnerabilities (Pinned Trivy and Grype) + id: scan-image-2 + uses: Lantero/shared-workflows/actions/scan-image@1c42928e7de3547403e934df7fa59a3e59865a75 # scan-action/v0.1.0 + with: + image_name: docker.io/redis:7.4.0-alpine + image_source: public + trivy_version: v0.66.0 + grype_version: v0.98.0 + + - name: (Test 3) Scan private DockerHub image for vulnerabilities + id: scan-image-3 + uses: Lantero/shared-workflows/actions/scan-image@1c42928e7de3547403e934df7fa59a3e59865a75 # scan-action/v0.1.0 + with: + image_name: ${{ github.repository }}@${{ steps.build.outputs.digest }} + image_source: private_dockerhub + + - name: (Test 4) Scan private GAR image for vulnerabilities + id: scan-image-4 + uses: Lantero/shared-workflows/actions/scan-image@1c42928e7de3547403e934df7fa59a3e59865a75 # scan-action/v0.1.0 + with: + image_name: us-docker.pkg.dev/grafanalabs-global/docker-deployment-tools-prod/terraform-team:1.7.5-16-0-amd64 + image_source: private_gar \ No newline at end of file diff --git a/actions/scan-image/CHANGELOG.md b/actions/scan-image/CHANGELOG.md new file mode 100644 index 000000000..15d2932dd --- /dev/null +++ b/actions/scan-image/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## 0.1.0 (2025-10-03) + +### 🎉 Features + +* **scan-image:** add `snyk` and `trivy` vulnerability scanners with a fail condition configuration +* **scan-image:** add [Dockerhub](https://hub.docker.com/) and [Google Artifact Registry](https://cloud.google.com/artifact-registry/docs) as available private sources \ No newline at end of file diff --git a/actions/scan-image/README.md b/actions/scan-image/README.md new file mode 100644 index 000000000..1daaa7b83 --- /dev/null +++ b/actions/scan-image/README.md @@ -0,0 +1,26 @@ +# scan-image + +This is a composite GitHub Action used to scan your images in search of vulnerabilities. + +The goal is to provide developers a way to check if their PR changes, without having to +wait for periodic scans (Faster feedback loop). This can also be used as part of deployment +CI/CD jobs as a way to verify things before shipping to production environments. + + + +```yaml +name: Scan image for vulnerabilities +jobs: + scan-image: + name: Scan image for vulnerabilities + steps: + - name: Scan image for vulnerabilities + id: scan-image + uses: grafana/shared-workflows/actions/scan-image@scan-image/v0.1.0 + with: + image_name: docker.io/hello-world + fail_on: critical + fail_on_threshold: 1 +``` + + diff --git a/actions/scan-image/action.yaml b/actions/scan-image/action.yaml new file mode 100644 index 000000000..b79d933da --- /dev/null +++ b/actions/scan-image/action.yaml @@ -0,0 +1,209 @@ +name: Scan Image +description: Composite action to check an image for vulnerabilities + +inputs: + image_name: + description: "The name of the image to scan, including the registry e.g. docker.io/hello-world" + required: true + image_source: + description: "The source of the image to scan" + type: choice + options: + - public + - private_dockerhub + - private_gar + default: public + fail_on: + description: "Whether to fail the workflow if vulnerabilities are found" + type: choice + options: + - critical + - high + - medium + - low + default: critical + fail_on_threshold: + description: "The threshold of vulnerabilities to fail the workflow on" + type: integer + default: 1 + trivy_version: + description: "The version of Trivy to use (The latest will be used if not provided)" + type: string + grype_version: + description: "The version of Grype to use (The latest will be used if not provided)" + type: string + +outputs: {} + +runs: + using: composite + steps: + + - name: Login to DockerHub + id: login-to-dockerhub + if: inputs.image_source == 'private_dockerhub' + uses: grafana/shared-workflows/actions/dockerhub-login@dockerhub-login-v1.0.1 + + - name: Extract GAR registry + id: extract-gar-registry + if: inputs.image_source == 'private_gar' + shell: bash + run: | + IMAGE_NAME="${{ inputs.image_name }}" + REGISTRY="${IMAGE_NAME%%/*}" + echo "registry=${REGISTRY}" >> $GITHUB_OUTPUT + + - name: Login to GAR + id: login-to-gar + if: inputs.image_source == 'private_gar' + uses: grafana/shared-workflows/actions/login-to-gar@login-to-gar/v1.0.0 + with: + registry: ${{ steps.extract-gar-registry.outputs.registry }} + + - name: Setup Trivy (Latest) + id: setup-trivy-latest + if: inputs.trivy_version == '' + uses: aquasecurity/setup-trivy@v0.2.3 + + - name: Setup Trivy (Pinned) + id: setup-trivy-pinned + if: inputs.trivy_version != '' + uses: aquasecurity/setup-trivy@v0.2.3 + with: + version: ${{ inputs.trivy_version }} + + - name: Run Trivy + id: run-trivy + shell: bash + run: | + trivy image ${{ inputs.image_name }} -f json -o trivy.json + trivy convert --format cyclonedx --output trivy-cdx.json trivy.json + + - name: Setup Grype (Latest) + id: setup-grype-latest + if: inputs.grype_version == '' + uses: anchore/scan-action/download-grype@v7 + with: + grype-version: + + - name: Setup Grype (Pinned) + id: setup-grype-pinned + if: inputs.grype_version != '' + uses: anchore/scan-action/download-grype@v7 + with: + grype-version: ${{ inputs.grype_version }} + + - name: Run Grype + id: run-grype + shell: bash + run: | + ${{ inputs.grype_version == '' && steps.setup-grype-latest.outputs.cmd || steps.setup-grype-pinned.outputs.cmd }} ${{ inputs.image_name }} -o cyclonedx-json > grype-cdx.json + + - name: Merge reports + id: merge-reports + shell: bash + run: | + jq -s ' + (.[0].vulnerabilities // []) + (.[1].vulnerabilities // []) + | map({ + "id": .id, + "url": ("https://www.cve.org/CVERecord?id=" + .id), + "severity": ([.ratings[].severity] | map(select(. != null)) | .[0] // "N/A") + }) + | map(select(.id | startswith("CVE"))) + | reduce .[] as $item ({}; + .[$item.id] = { + "url": $item.url, + "severity": $item.severity + } + ) + ' trivy-cdx.json grype-cdx.json > result.json + cat result.json + + - name: Markdown summary + id: markdown-summary + shell: bash + run: | + # Check if result.json is empty or has no entries + if [[ $(jq '. | length' result.json) -eq 0 ]]; then + ## No CVEs found + echo "# CVE Report (${{ inputs.image_name }}) :white_check_mark: + + No CVEs found!" >> "$GITHUB_STEP_SUMMARY" + else + ## CVEs found, create table + MARKDOWN_CONTENT=" + ## CVE Report (${{ inputs.image_name }}) :warning: + + | CVE | Severity | + | :--- | :--- | + " + # Use 'jq' to process the JSON file. + # The output is formatted as tab-separated values (TSV) for easy reading into bash variables. + # .key is the CVE ID, .value.severity is the severity string, and .value.url is the URL. + while IFS=$'\t' read -r cve_id severity url; do + # Append a row to the markdown content + # The CVE ID is formatted as a link: [CVE-ID](URL) + # The Severity is kept as plain text. + MARKDOWN_CONTENT+="| **[$cve_id]($url)** | $severity | + " + done < <(jq -r '. | to_entries[] | "\(.key)\t\(.value.severity)\t\(.value.url)"' result.json) + echo "$MARKDOWN_CONTENT" >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Fail condition + id: fail-condition + shell: bash + run: | + if [[ ${{ inputs.fail_on_threshold }} -lt 1 ]]; then + echo "fail_on_threshold is less than 1, skipping fail condition check" + exit 0 + fi + + declare -A severity_levels=( + ["low"]=1 + ["medium"]=2 + ["high"]=3 + ["critical"]=4 + ) + + echo "Checking for vulnerabilities with severity >= ${{ inputs.fail_on }} (level ${{ inputs.fail_on_threshold }})" + echo "Threshold: ${{ inputs.fail_on_threshold }} vulnerabilities" + + vulnerability_count=0 + + if [[ ! -f "result.json" ]] || [[ $(jq '. | length' result.json) -eq 0 ]]; then + echo "No vulnerabilities found in scan results" + exit 0 + fi + + # Count vulnerabilities at or above the specified severity level + while IFS=$'\t' read -r cve_id severity url; do + # Convert severity to lowercase for comparison + severity_lower=$(echo "$severity" | tr '[:upper:]' '[:lower:]') + + # Skip if severity is not in our defined levels (e.g., "N/A") + if [[ -z "${severity_levels[$severity_lower]}" ]]; then + echo "Skipping $cve_id with unknown severity: $severity" + continue + fi + + current_level=${severity_levels[$severity_lower]} + + if [[ $current_level -ge $${{ inputs.fail_on }} ]]; then + echo "Found vulnerability: $cve_id (severity: $severity, level: $current_level)" + ((vulnerability_count++)) + fi + done < <(jq -r '. | to_entries[] | "\(.key)\t\(.value.severity)\t\(.value.url)"' result.json) + + echo "Found $vulnerability_count vulnerabilities at or above ${{ inputs.fail_on }} severity level" + + # Fail if vulnerability count meets or exceeds threshold + if [[ $vulnerability_count -ge ${{ inputs.fail_on_threshold }} ]]; then + echo "❌ FAIL: Found $vulnerability_count vulnerabilities (threshold: ${{ inputs.fail_on_threshold }})" + echo "Vulnerabilities at or above '${{ inputs.fail_on }}' severity level exceed the specified threshold." + exit 1 + else + echo "✅ PASS: Found $vulnerability_count vulnerabilities (threshold: ${{ inputs.fail_on_threshold }})" + exit 0 + fi