HPCC-35847 Add HELM chart CVE scan workflow #18
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Helm Charts Vulnerability Scan (CVE only) | |
| on: | |
| workflow_dispatch: | |
| pull_request: | |
| branches: [ master, 'candidate-*' ] | |
| paths: [ 'helm/**', '.github/workflows/helm-vuln-scan.yml' ] | |
| # Default: least privilege for GITHUB_TOKEN (defense-in-depth) | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: helm-cve-scan-${{ github.workflow }}-${{ github.ref || github.run_id }} | |
| cancel-in-progress: true | |
| jobs: | |
| trivy-cve-scan: | |
| name: Vulnerability-only scan of images referenced by changed Helm charts | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 30 | |
| # Job-level reassertion of minimal permissions (defense-in-depth) | |
| permissions: | |
| contents: read | |
| # --- Policy switch: | |
| # Set to "true" if you want forked PRs that skip `helm dependency build` to FAIL the job. | |
| # Default is "false" which will surface a WARNING instead (visible in the run log/annotations). | |
| env: | |
| FAIL_ON_NON_ALLOWLISTED_REPO: "false" | |
| # Allowlist: approve only these Helm repo hostnames (tighten per org policy as needed) | |
| ALLOWLIST_DOMAINS_REGEX: '^https?://(hpcc-systems\.github\.io|helm\.elastic\.co|open-telemetry\.github\.io|grafana\.github\.io|prometheus-community\.github\.io|kubernetes\.github\.io)($|[:/])' | |
| steps: | |
| # ----------------------------------------------------------------------- | |
| # CHECKOUT — using pinned commit SHA for supply chain security and maintainability | |
| # ----------------------------------------------------------------------- | |
| - name: Checkout | |
| uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 | |
| with: | |
| # fetch-depth 0 enables diffing and commit lookups across PR base/head | |
| fetch-depth: 0 | |
| # ----------------------------------------------------------------------- | |
| # HELM — using stable semantic version for maintainability | |
| # ----------------------------------------------------------------------- | |
| - name: Set up Helm | |
| uses: Azure/setup-helm@v4.3.1 | |
| # ----------------------------------------------------------------------- | |
| # TRIVY — using stable semantic version for maintainability | |
| # ----------------------------------------------------------------------- | |
| - name: Set up Trivy (CLI) | |
| uses: aquasecurity/setup-trivy@e07451d2e059ed86c2870430ea286b3a9e0bf241 | |
| with: | |
| cache: true | |
| version: 'v0.69.2' | |
| # Utility: jq for JSON summarization | |
| - name: Ensure jq is available | |
| shell: bash --noprofile --norc {0} | |
| run: | | |
| if ! command -v jq >/dev/null 2>&1; then | |
| sudo apt-get update -y | |
| sudo apt-get install -y jq | |
| fi | |
| # ----------------------------------------------------------------------- | |
| # DISCOVER CHARTS CHANGED IN THIS PR | |
| # - We diff against PR base SHA; if not a PR, scan all charts under helm/ | |
| # ----------------------------------------------------------------------- | |
| - name: Identify changed/added Helm charts in PR (safe parsing) | |
| id: changed_charts | |
| shell: bash --noprofile --norc {0} | |
| run: | | |
| set -euo pipefail | |
| shopt -s nullglob | |
| declare -a CHART_DIRS=() | |
| if [[ "${GITHUB_EVENT_NAME}" != "pull_request" ]]; then | |
| # Scan all charts for workflow_dispatch | |
| while IFS= read -r -d '' d; do | |
| CHART_DIRS+=("$d") | |
| done < <(find helm -type f -name Chart.yaml -printf '%h\0' | sort -zu) | |
| else | |
| # For PRs, find charts containing any changed files | |
| # Since workflow only triggers on helm/** changes, we know files changed | |
| BASE_SHA="${{ github.event.pull_request.base.sha }}" | |
| echo "Checking for changed files since ${BASE_SHA}..." | |
| # Simple approach: get chart dirs for all changed helm files | |
| while IFS= read -r file; do | |
| [[ -n "$file" ]] || continue | |
| echo "Processing changed file: $file" | |
| dir="$(dirname "$file")" | |
| while [[ "$dir" != "." && "$dir" != "/" ]]; do | |
| if [[ -f "$dir/Chart.yaml" ]]; then | |
| echo "Found chart directory: $dir" | |
| CHART_DIRS+=("$dir") | |
| break | |
| fi | |
| dir="$(dirname "$dir")" | |
| done | |
| done < <(git diff --name-only "${BASE_SHA}...HEAD" -- 'helm/**' 2>/dev/null || true) | |
| # Remove duplicates safely | |
| if [[ ${#CHART_DIRS[@]} -gt 0 ]]; then | |
| readarray -t CHART_DIRS < <(printf '%s\n' "${CHART_DIRS[@]}" | sort -u) | |
| fi | |
| fi | |
| if [[ ${#CHART_DIRS[@]} -eq 0 ]]; then | |
| echo "No charts found to scan." | |
| echo "charts=" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| printf "Changed/added charts to scan:\n" | |
| printf " - %s\n" "${CHART_DIRS[@]}" | |
| { | |
| printf "charts=" | |
| printf "%s " "${CHART_DIRS[@]}" | |
| echo | |
| } >> "$GITHUB_OUTPUT" | |
| # ----------------------------------------------------------------------- | |
| # RENDER & COLLECT IMAGES, WITH HELM REPO ALLOWLIST | |
| # - Build dependencies only for charts with allowlisted repositories | |
| # - Skip charts with non-allowlisted repos (can be made to fail via policy flag) | |
| # ----------------------------------------------------------------------- | |
| - name: Render charts and collect images + mapping (with repo allowlist) | |
| id: collect | |
| if: steps.changed_charts.outputs.charts != '' | |
| shell: bash --noprofile --norc {0} | |
| env: | |
| CHARTS: ${{ steps.changed_charts.outputs.charts }} | |
| run: | | |
| set -euo pipefail | |
| shopt -s nullglob | |
| TMP_IMAGES="$(mktemp)" | |
| MAP_FILE="image_chart_map.csv" | |
| : > "${MAP_FILE}" | |
| chart_name() { awk 'tolower($1)=="name:" {print $2; exit}' "$1" 2>/dev/null || true; } | |
| #Check for dependencies in all possible files | |
| check_for_dependencies() | |
| { | |
| local chart="$1" | |
| local dependency_files=("${chart}/Chart.yaml" "${chart}/Chart.lock" "${chart}/requirements.yaml" "${chart}/requirements.lock") | |
| # Add values files that might contain repository references | |
| dependency_files+=("${chart}/values.yaml") | |
| # Check for environment-specific values files | |
| shopt -s nullglob | |
| dependency_files+=("${chart}/values-"*.yaml) | |
| dependency_files+=("${chart}/values/"*.yaml) | |
| dependency_files+=("${chart}/repositories.yaml") | |
| shopt -u nullglob | |
| # Check charts/ subdirectory for vendored dependencies | |
| local charts_dir="${chart}/charts" | |
| if [[ -d "$charts_dir" ]]; then | |
| shopt -s nullglob | |
| dependency_files+=("${charts_dir}"/*/Chart.yaml) | |
| # Check for .tgz files (packaged charts indicate dependencies) | |
| if ls "${charts_dir}"/*.tgz >/dev/null 2>&1; then | |
| return 0 # Packaged dependencies present | |
| fi | |
| shopt -u nullglob | |
| fi | |
| for file in "${dependency_files[@]}"; do | |
| [[ -f "$file" ]] || continue | |
| # Enhanced regex for multiple URL schemes and contexts | |
| if grep -Eqi '(repository|url):[[:space:]]*["\047]?(https?://|oci://|git(\+https?|\+ssh)?://|file://)[^[:space:]"\047]+["\047]?' "$file"; then | |
| return 0 | |
| fi | |
| done | |
| return 1 | |
| } | |
| # Extract all repository URLs from dependency files | |
| extract_all_repos() | |
| { | |
| local chart="$1" | |
| local dependency_files=("${chart}/Chart.yaml" "${chart}/Chart.lock" "${chart}/requirements.yaml" "${chart}/requirements.lock") | |
| # Add values files and other potential sources | |
| dependency_files+=("${chart}/values.yaml") | |
| shopt -s nullglob | |
| dependency_files+=("${chart}/values-"*.yaml) | |
| dependency_files+=("${chart}/values/"*.yaml) | |
| dependency_files+=("${chart}/repositories.yaml") | |
| # Include vendored chart dependencies | |
| local charts_dir="${chart}/charts" | |
| if [[ -d "$charts_dir" ]]; then | |
| dependency_files+=("${charts_dir}"/*/Chart.yaml) | |
| fi | |
| shopt -u nullglob | |
| local temp_repos="$(mktemp)" | |
| for file in "${dependency_files[@]}"; do | |
| [[ -f "$file" ]] || continue | |
| # Extract URLs for multiple schemes, handling both quoted and unquoted formats | |
| grep -Ehio '(repository|url):[[:space:]]*["\047]?(https?://|oci://|git(\+https?|\+ssh)?://|file://)[^[:space:]"\047]+["\047]?' "$file" 2>/dev/null | \ | |
| sed -E 's/^(repository|url):[[:space:]]*["\047]?//; s/["\047]?$//' >> "$temp_repos" || true | |
| done | |
| if [[ -s "$temp_repos" ]]; then | |
| sort -u "$temp_repos" | |
| fi | |
| rm -f "$temp_repos" | |
| } | |
| for chart in ${CHARTS}; do | |
| echo "::group::Template chart: ${chart}" | |
| # Build dependencies if the chart has them and all repos are allowlisted | |
| if check_for_dependencies "${chart}"; then | |
| ALL_REPOS=$(extract_all_repos "${chart}") | |
| NON_ALLOWLISTED=$(printf "%s\n" "${ALL_REPOS}" | grep -Eiv "${ALLOWLIST_DOMAINS_REGEX}" || true) | |
| if [[ -n "${NON_ALLOWLISTED}" ]]; then | |
| if [[ "${FAIL_ON_NON_ALLOWLISTED_REPO}" == "true" ]]; then | |
| echo "::error file=${chart}/Chart.yaml::Non-allowlisted Helm repository detected in ${chart}: ${NON_ALLOWLISTED//$'\n'/, }" | |
| exit 1 | |
| else | |
| echo "::warning file=${chart}/Chart.yaml::Skipping ${chart} due to non-allowlisted repository: ${NON_ALLOWLISTED//$'\n'/, }" | |
| echo "::endgroup::" | |
| continue | |
| fi | |
| else | |
| echo "Building dependencies for ${chart} (all repositories allowlisted)..." | |
| helm dependency build "${chart}" || { | |
| echo "::warning file=${chart}/Chart.yaml::Failed to build dependencies for ${chart}, skipping chart" | |
| echo "::endgroup::" | |
| continue | |
| } | |
| fi | |
| fi | |
| # Some charts require a deterministic .Release.Name during template | |
| # Example: eck chart paths that key behaviors off release name | |
| rel_name="$(basename "${chart}")" | |
| c_name="$(chart_name "${chart}/Chart.yaml")" | |
| if [[ "${chart}" == *"/managed/observability/eck" ]] || [[ "${c_name}" == "eck4hpccobservability" ]]; then | |
| rel_name="eck-apm" | |
| fi | |
| # Render manifest and extract image references with proper error handling | |
| echo "Rendering chart ${chart} with release name: ${rel_name}" | |
| # Capture helm template output and exit code separately | |
| helm_output_file="$(mktemp)" | |
| if helm template "${rel_name}" "${chart}" > "${helm_output_file}" 2>&1; then | |
| echo "Successfully rendered chart ${chart}" | |
| # Extract image references from successful template output | |
| image_count=0 | |
| while IFS= read -r img; do | |
| # normalize quotes occasionally introduced by templating | |
| img="${img%\"}"; img="${img#\"}"; img="${img%\'}"; img="${img#\'}" | |
| [[ -z "$img" ]] && continue | |
| echo "$img" >> "${TMP_IMAGES}" | |
| printf "%s,%s\n" "$img" "$chart" >> "${MAP_FILE}" | |
| ((image_count++)) | |
| done < <(grep -hoE 'image:\s*[^[:space:]]+' "${helm_output_file}" 2>/dev/null | awk '{print $2}' || true) | |
| if [[ ${image_count} -eq 0 ]]; then | |
| echo "::notice file=${chart}/Chart.yaml::Chart ${chart} rendered successfully but contains no container images to scan" | |
| else | |
| echo "Found ${image_count} image(s) in chart ${chart}" | |
| fi | |
| else | |
| echo "::warning file=${chart}/Chart.yaml::Failed to render chart ${chart}. Template output: $(head -n 5 "${helm_output_file}" | tr '\n' ' ; ')..." | |
| echo "Chart ${chart} will be skipped from vulnerability scanning due to template failure" | |
| fi | |
| rm -f "${helm_output_file}" | |
| echo "::endgroup::" | |
| done | |
| if [[ ! -s "${TMP_IMAGES}" ]]; then | |
| echo "images=" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| sort -u "${TMP_IMAGES}" -o "${TMP_IMAGES}" | |
| echo "Images to scan:"; cat "${TMP_IMAGES}" | |
| echo "images=$(tr '\n' ' ' < "${TMP_IMAGES}")" >> "$GITHUB_OUTPUT" | |
| echo "map_file=${MAP_FILE}" >> "$GITHUB_OUTPUT" | |
| # ----------------------------------------------------------------------- | |
| # TRIVY SCAN — Gate on CRITICAL; report HIGH as informational | |
| # - Annotations show CRIT count and Top-3 IDs (full details in Step Summary) | |
| # ----------------------------------------------------------------------- | |
| - name: Scan images for CVEs (CRITICAL-gated; HIGH informational) | |
| if: steps.collect.outputs.images != '' | |
| shell: bash --noprofile --norc {0} | |
| env: | |
| IMAGES: ${{ steps.collect.outputs.images }} | |
| MAP_FILE: ${{ steps.collect.outputs.map_file }} | |
| RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| run: | | |
| set -euo pipefail | |
| SUMMARY="${GITHUB_STEP_SUMMARY}" | |
| : > trivy-summary.md | |
| CRIT_TMP="$(mktemp)" | |
| HIGH_TMP="$(mktemp)" | |
| EXIT=0 | |
| for image in ${IMAGES}; do | |
| echo "::group::Trivy vulnerability scan: ${image}" | |
| # Skip non-concrete image placeholders/templated tokens | |
| if [[ "${image}" == *"{{"* ]] || { [[ "${image}" == "<"* ]] && [[ "${image}" == *">" ]]; }; then | |
| echo "Skipping templated/placeholder image ref: ${image}"; echo "::endgroup::"; continue | |
| fi | |
| REPORT="$(mktemp --suffix=.json)" | |
| if ! trivy image \ | |
| --scanners vuln \ | |
| --pkg-types os,library \ | |
| --format json \ | |
| --quiet \ | |
| "${image}" > "${REPORT}"; then | |
| echo "WARNING: Trivy returned non-zero for ${image}, continuing." | |
| echo '{"Results":[]}' > "${REPORT}" | |
| fi | |
| CHARTS=$(awk -F',' -v img="$image" '$1==img {print $2}' "${MAP_FILE}" | sort -u | tr '\n' ', ' | sed 's/, $//') | |
| [[ -z "${CHARTS}" ]] && CHARTS="(unknown chart)" | |
| PRIMARY_CHART=$(awk -F',' -v img="$image" '$1==img {print $2}' "${MAP_FILE}" | sort -u | head -n 1) | |
| ANN_FILE="${PRIMARY_CHART}/Chart.yaml"; [[ -f "${ANN_FILE}" ]] || ANN_FILE="${PRIMARY_CHART}" | |
| # ---- CRITICAL handling: fail + annotate with count and Top-3 IDs ---- | |
| CRIT_COUNT=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="CRITICAL")] | length' "${REPORT}" 2>/dev/null || echo "0") | |
| if [[ "${CRIT_COUNT}" -gt 0 ]]; then | |
| EXIT=1 | |
| # Step Summary (full details) | |
| { | |
| echo "### Image: \`${image}\`" | |
| echo "- **Source chart(s):** ${CHARTS}" | |
| echo "" | |
| echo "| CVE | Package | Installed | Fixed | Title |" | |
| echo "|---|---|---|---|---|" | |
| jq -r ' | |
| .Results[]?.Vulnerabilities[]? | |
| | select(.Severity=="CRITICAL") | |
| | "| \(.VulnerabilityID) | \(.PkgName) | \(.InstalledVersion) | \((.FixedVersion // "n/a")) | \((.Title // "")) |" | |
| ' "${REPORT}" 2>/dev/null || echo "| Error parsing vulnerabilities | - | - | - | - |" | |
| echo "" | |
| } >> "${CRIT_TMP}" | |
| TOP3_CVES=$( | |
| jq -r ' | |
| .Results[]?.Vulnerabilities[]? | |
| | select(.Severity=="CRITICAL") | |
| | .VulnerabilityID | |
| ' "${REPORT}" 2>/dev/null | head -n 3 | paste -sd',' - | |
| ) | |
| if [[ -n "${TOP3_CVES}" ]]; then | |
| echo "::error file=${ANN_FILE}::CRITICAL CVEs (${CRIT_COUNT}) found in ${image} from chart(s): ${CHARTS}. Top IDs: ${TOP3_CVES}. See run summary: ${RUN_URL}" | |
| else | |
| echo "::error file=${ANN_FILE}::CRITICAL CVEs (${CRIT_COUNT}) found in ${image} from chart(s): ${CHARTS}. See run summary: ${RUN_URL}" | |
| fi | |
| fi | |
| # ---- HIGH handling: informational only; capture in summary ---- | |
| HIGH_COUNT=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="HIGH")] | length' "${REPORT}" 2>/dev/null || echo "0") | |
| if [[ "${HIGH_COUNT}" -gt 0 ]]; then | |
| { | |
| echo "### Image: \`${image}\`" | |
| echo "- **Source chart(s):** ${CHARTS}" | |
| echo "" | |
| echo "| CVE | Package | Installed | Fixed | Title |" | |
| echo "|---|---|---|---|---|" | |
| jq -r ' | |
| .Results[]?.Vulnerabilities[]? | |
| | select(.Severity=="HIGH") | |
| | "| \(.VulnerabilityID) | \(.PkgName) | \(.InstalledVersion) | \((.FixedVersion // "n/a")) | \((.Title // "")) |" | |
| ' "${REPORT}" 2>/dev/null || echo "| Error parsing vulnerabilities | - | - | - | - |" | |
| echo "" | |
| } >> "${HIGH_TMP}" | |
| fi | |
| echo "::endgroup::" | |
| done | |
| { | |
| echo "# Helm Chart Vulnerability Summary" | |
| echo "" | |
| echo "- **Scope:** Only charts changed/added in this PR" | |
| echo "- **Gating policy:** Fail on **CRITICAL**; **HIGH** is informational" | |
| echo "- **Run URL:** ${RUN_URL}" | |
| echo "" | |
| echo "## CRITICAL Findings (cause failure)" | |
| echo "" | |
| if [[ -s "${CRIT_TMP}" ]]; then cat "${CRIT_TMP}"; else echo "_No CRITICAL findings._"; echo ""; fi | |
| echo "## HIGH Findings (informational)" | |
| echo "" | |
| if [[ -s "${HIGH_TMP}" ]]; then cat "${HIGH_TMP}"; else echo "_No HIGH findings._"; echo ""; fi | |
| } | tee "${SUMMARY}" trivy-summary.md >/dev/null | |
| if [[ "${EXIT}" -ne 0 ]]; then | |
| echo "::error::CRITICAL vulnerabilities detected. See Step Summary above." | |
| exit 1 | |
| fi | |
| # ----------------------------------------------------------------------- | |
| # ARTIFACTS — Upload summary using stable semantic version | |
| # If you run GHES, swap to actions/upload-artifact@v3.* (v4 isn't supported on GHES). | |
| # ----------------------------------------------------------------------- | |
| - name: Upload Trivy summary (artifact) | |
| if: always() | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 | |
| with: | |
| name: trivy-summary | |
| path: trivy-summary.md | |
| retention-days: 7 |