Skip to content

HPCC-35847 Add HELM chart CVE scan workflow #18

HPCC-35847 Add HELM chart CVE scan workflow

HPCC-35847 Add HELM chart CVE scan workflow #18

Workflow file for this run

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