diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..39b8bc6 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,274 @@ +# +# Copyright 2026 ABSA Group Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +name: Test + +on: + pull_request: + branches: [ master ] + push: + branches: [ master ] + +permissions: + contents: read + +jobs: + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + # Action uses bash 4+ features (mapfile, associative arrays). + # macOS runners ship bash 3.2 — install a modern version via Homebrew. + - name: Install bash (macOS) + if: runner.os == 'macOS' + run: brew install bash + + - name: Generate test fixtures + shell: bash + run: | + set -euo pipefail + + FIXTURES_DIR="tests/fixtures" + CA_DIR="$(pwd)/$FIXTURES_DIR/ca" + mkdir -p "$CA_DIR/newcerts" + touch "$CA_DIR/index.txt" + echo "1000" > "$CA_DIR/serial" + + cat > "$CA_DIR/openssl.cnf" << EOF + [ca] + default_ca = test_ca + [test_ca] + dir = ${CA_DIR} + database = \$dir/index.txt + new_certs_dir = \$dir/newcerts + serial = \$dir/serial + certificate = \$dir/ca.pem + private_key = \$dir/ca.key + default_md = sha256 + policy = policy_any + unique_subject = no + [policy_any] + commonName = supplied + [req] + distinguished_name = req_dn + prompt = no + [req_dn] + EOF + + openssl req -x509 -newkey rsa:2048 -keyout "$CA_DIR/ca.key" -out "$CA_DIR/ca.pem" \ + -days 7300 -nodes -subj "/CN=Test CA" 2>/dev/null + + sign_cert() { + local out="$1" cn="$2" startdate="$3" enddate="$4" + local tmpkey tmpcsr + tmpkey=$(mktemp) + tmpcsr=$(mktemp) + openssl req -newkey rsa:2048 -keyout "$tmpkey" -out "$tmpcsr" \ + -nodes -subj "/CN=$cn" 2>/dev/null + openssl ca -batch -config "$CA_DIR/openssl.cnf" \ + -startdate "$startdate" -enddate "$enddate" \ + -in "$tmpcsr" -out "$out" -notext 2>/dev/null + rm -f "$tmpkey" "$tmpcsr" + } + + YEAR=$(date -u +%Y) + YY_2AGO=$(printf "%02d" $(( (YEAR - 2) % 100 ))) + YY_1AGO=$(printf "%02d" $(( (YEAR - 1) % 100 ))) + YY_1AHEAD=$(printf "%02d" $(( (YEAR + 1) % 100 ))) + YY_2AHEAD=$(printf "%02d" $(( (YEAR + 2) % 100 ))) + + openssl req -x509 -newkey rsa:2048 -keyout /dev/null \ + -out "$FIXTURES_DIR/valid.pem" -days 365 -nodes \ + -subj "/CN=valid.example.com" 2>/dev/null + + openssl req -x509 -newkey rsa:2048 -keyout /dev/null \ + -out "$FIXTURES_DIR/expiring.pem" -days 10 -nodes \ + -subj "/CN=expiring.example.com" 2>/dev/null + + sign_cert "$FIXTURES_DIR/expired.pem" "expired.example.com" \ + "${YY_2AGO}0101000000Z" "${YY_1AGO}0101000000Z" + + sign_cert "$FIXTURES_DIR/not_yet_valid.pem" "notyetvalid.example.com" \ + "${YY_1AHEAD}0101000000Z" "${YY_2AHEAD}0101000000Z" + + sign_cert "$FIXTURES_DIR/multi_expired.pem" "multi.example.com" \ + "${YY_2AGO}0101000000Z" "${YY_1AGO}0101000000Z" + + openssl req -x509 -newkey rsa:2048 -keyout /dev/null \ + -out "$FIXTURES_DIR/multi_valid.pem" -days 365 -nodes \ + -subj "/CN=multi.example.com" 2>/dev/null + + # Expiring-soon cert sharing a subject with multi_valid.pem, used to + # prove fail_on_warn does not fail when a newer valid replacement exists. + openssl req -x509 -newkey rsa:2048 -keyout /dev/null \ + -out "$FIXTURES_DIR/multi_expiring.pem" -days 10 -nodes \ + -subj "/CN=multi.example.com" 2>/dev/null + + echo "Fixtures generated:" + ls -la "$FIXTURES_DIR"/*.pem + + - name: 'Test: valid certificate' + shell: bash + run: | + SUMMARY=$(mktemp) + INPUT_CERTIFICATES='["tests/fixtures/valid.pem"]' \ + INPUT_WARNING_DAYS=30 \ + GITHUB_STEP_SUMMARY="$SUMMARY" \ + bash validate.sh + grep -q "✅ Valid" "$SUMMARY" + rm -f "$SUMMARY" + + - name: 'Test: expiring-soon certificate' + shell: bash + run: | + SUMMARY=$(mktemp) + INPUT_CERTIFICATES='["tests/fixtures/expiring.pem"]' \ + INPUT_WARNING_DAYS=30 \ + GITHUB_STEP_SUMMARY="$SUMMARY" \ + bash validate.sh + grep -q "Expiring Soon" "$SUMMARY" + rm -f "$SUMMARY" + + - name: 'Test: expired certificate' + shell: bash + run: | + SUMMARY=$(mktemp) + EXIT=0 + INPUT_CERTIFICATES='["tests/fixtures/expired.pem"]' \ + INPUT_WARNING_DAYS=30 \ + GITHUB_STEP_SUMMARY="$SUMMARY" \ + bash validate.sh || EXIT=$? + if [[ "$EXIT" -ne 1 ]]; then + echo "::error::Expected exit code 1 for expired cert, got $EXIT" + exit 1 + fi + grep -q "Expired" "$SUMMARY" + rm -f "$SUMMARY" + + - name: 'Test: not-yet-valid certificate' + shell: bash + run: | + SUMMARY=$(mktemp) + EXIT=0 + INPUT_CERTIFICATES='["tests/fixtures/not_yet_valid.pem"]' \ + INPUT_WARNING_DAYS=30 \ + GITHUB_STEP_SUMMARY="$SUMMARY" \ + bash validate.sh || EXIT=$? + if [[ "$EXIT" -ne 1 ]]; then + echo "::error::Expected exit code 1 for not-yet-valid cert, got $EXIT" + exit 1 + fi + grep -q "Failed" "$SUMMARY" + rm -f "$SUMMARY" + + - name: 'Test: missing file' + shell: bash + run: | + SUMMARY=$(mktemp) + EXIT=0 + INPUT_CERTIFICATES='["/nonexistent/cert.pem"]' \ + INPUT_WARNING_DAYS=30 \ + GITHUB_STEP_SUMMARY="$SUMMARY" \ + bash validate.sh || EXIT=$? + if [[ "$EXIT" -ne 1 ]]; then + echo "::error::Expected exit code 1 for missing file, got $EXIT" + exit 1 + fi + grep -q "Failed" "$SUMMARY" + rm -f "$SUMMARY" + + - name: 'Test: multi-cert with valid replacement' + shell: bash + run: | + SUMMARY=$(mktemp) + INPUT_CERTIFICATES='["tests/fixtures/multi_expired.pem", "tests/fixtures/multi_valid.pem"]' \ + INPUT_WARNING_DAYS=30 \ + GITHUB_STEP_SUMMARY="$SUMMARY" \ + bash validate.sh + grep -q "newer replacement exists" "$SUMMARY" + rm -f "$SUMMARY" + + - name: 'Test: fail_on_warn fails on expiring-soon certificate' + shell: bash + run: | + SUMMARY=$(mktemp) + EXIT=0 + INPUT_CERTIFICATES='["tests/fixtures/expiring.pem"]' \ + INPUT_WARNING_DAYS=30 \ + INPUT_FAIL_ON_WARN=true \ + GITHUB_STEP_SUMMARY="$SUMMARY" \ + bash validate.sh || EXIT=$? + if [[ "$EXIT" -ne 1 ]]; then + echo "::error::Expected exit code 1 for fail_on_warn=true with expiring cert, got $EXIT" + exit 1 + fi + grep -q "Expiring Soon" "$SUMMARY" + rm -f "$SUMMARY" + + - name: 'Test: fail_on_warn does not fail when expiring cert has valid replacement' + shell: bash + run: | + SUMMARY=$(mktemp) + INPUT_CERTIFICATES='["tests/fixtures/multi_expiring.pem", "tests/fixtures/multi_valid.pem"]' \ + INPUT_WARNING_DAYS=30 \ + INPUT_FAIL_ON_WARN=true \ + GITHUB_STEP_SUMMARY="$SUMMARY" \ + bash validate.sh + # Must NOT fail (newer valid replacement exists, so WARN_COUNT stays 0), + # and the summary status must reflect the nearing-expiry cert. + grep -q "Expiring Soon (newer replacement exists)" "$SUMMARY" + rm -f "$SUMMARY" + + - name: 'Test: invalid JSON input' + shell: bash + run: | + OUTPUT=$(mktemp) + EXIT=0 + INPUT_CERTIFICATES='not-valid-json' \ + INPUT_WARNING_DAYS=30 \ + bash validate.sh > "$OUTPUT" 2>&1 || EXIT=$? + if [[ "$EXIT" -ne 1 ]]; then + echo "::error::Expected exit code 1 for invalid JSON input, got $EXIT" + cat "$OUTPUT" + exit 1 + fi + grep -q "must be a valid JSON array" "$OUTPUT" + rm -f "$OUTPUT" + + - name: 'Test: valid JSON but not an array' + shell: bash + run: | + OUTPUT=$(mktemp) + EXIT=0 + INPUT_CERTIFICATES='{"cert": "tests/fixtures/valid.pem"}' \ + INPUT_WARNING_DAYS=30 \ + bash validate.sh > "$OUTPUT" 2>&1 || EXIT=$? + if [[ "$EXIT" -ne 1 ]]; then + echo "::error::Expected exit code 1 for non-array JSON input, got $EXIT" + cat "$OUTPUT" + exit 1 + fi + grep -q "must be a valid JSON array" "$OUTPUT" + rm -f "$OUTPUT" diff --git a/.gitignore b/.gitignore index afa1b6d..42597c3 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ Thumbs.db # Local test scripts run_locally.sh + +# Generated test fixtures +tests/fixtures/ diff --git a/action.yml b/action.yml index 022b021..0a0f606 100644 --- a/action.yml +++ b/action.yml @@ -55,302 +55,4 @@ runs: INPUT_WARNING_DAYS: ${{ inputs.warning_days }} INPUT_FAIL_ON_WARN: ${{ inputs.fail_on_warn }} shell: bash - run: | - set -uo pipefail - - FAIL_ON_WARN="${INPUT_FAIL_ON_WARN,,}" - if [[ "$FAIL_ON_WARN" != "true" && "$FAIL_ON_WARN" != "false" ]]; then - echo "::error::INPUT_FAIL_ON_WARN must be 'true' or 'false', got: '$INPUT_FAIL_ON_WARN'" - exit 1 - fi - - if [[ ! "$INPUT_WARNING_DAYS" =~ ^[0-9]+$ ]]; then - echo "::error::INPUT_WARNING_DAYS must be a non-negative integer, got: '$INPUT_WARNING_DAYS'" - exit 1 - fi - - if ! echo "$INPUT_CERTIFICATES" | jq -e 'type == "array"' > /dev/null 2>&1; then - echo "::error::INPUT_CERTIFICATES must be a valid JSON array, got: '$INPUT_CERTIFICATES'" - exit 1 - fi - - CERTIFICATES="$INPUT_CERTIFICATES" - WARNING_DAYS=$((10#$INPUT_WARNING_DAYS)) - - # Parse JSON array into a bash array - mapfile -t CERT_FILES < <(echo "$CERTIFICATES" | jq -r '.[]') - - if [[ ${#CERT_FILES[@]} -eq 0 ]]; then - echo "::error::No certificate files provided in the JSON array." - exit 1 - fi - - echo "🔍 Found ${#CERT_FILES[@]} certificate(s) to validate." - - WARN_COUNT=0 - FAIL_COUNT=0 - declare -a SUMMARY_ROWS=() - - # Associative arrays for grouping certs by subject - declare -A SUBJECT_BEST_EXPIRY=() # subject -> best (latest) notAfter epoch - declare -A SUBJECT_BEST_STATUS=() # subject -> best status (OK, WARN, FAIL) - declare -A SUBJECT_CERTS=() # subject -> space-separated list of cert indices - declare -a CERT_STATUS=() # per-cert status - declare -a CERT_SUBJECT=() # per-cert subject - declare -a CERT_EXPIRY_EPOCH=() # per-cert expiry epoch - declare -a CERT_NAME_ARR=() # per-cert basename - - # ------------------------------------------------------- - # Helper: convert OpenSSL date string to epoch - # Format: "Sep 18 08:57:36 2025 GMT" - # ------------------------------------------------------- - convert_cert_date_to_epoch() { - local cert_date="$1" - if [[ $cert_date =~ ^([A-Z][a-z]+)\ +([0-9]+)\ +([0-9]{2}):([0-9]{2}):([0-9]{2})\ +([0-9]{4})\ +GMT$ ]]; then - local month="${BASH_REMATCH[1]}" - local day="${BASH_REMATCH[2]}" - local hour="${BASH_REMATCH[3]}" - local minute="${BASH_REMATCH[4]}" - local second="${BASH_REMATCH[5]}" - local year="${BASH_REMATCH[6]}" - - case "$month" in - Jan) month_num=01 ;; Feb) month_num=02 ;; Mar) month_num=03 ;; - Apr) month_num=04 ;; May) month_num=05 ;; Jun) month_num=06 ;; - Jul) month_num=07 ;; Aug) month_num=08 ;; Sep) month_num=09 ;; - Oct) month_num=10 ;; Nov) month_num=11 ;; Dec) month_num=12 ;; - *) echo "Invalid month: $month" >&2; return 1 ;; - esac - - day=$(printf "%02d" "$day") - - # Works on both Linux (GNU date) and macOS (BSD date) - date -u -d "${year}-${month_num}-${day} ${hour}:${minute}:${second}" +%s 2>/dev/null || \ - date -u -j -f "%Y-%m-%d %H:%M:%S" "${year}-${month_num}-${day} ${hour}:${minute}:${second}" +%s 2>/dev/null - else - echo "Invalid date format: $cert_date" >&2 - return 1 - fi - } - - # ------------------------------------------------------- - # Validate a single certificate and output its info. - # Sets global variables: _STATUS, _SUBJECT, _EXPIRY_EPOCH - # ------------------------------------------------------- - validate_cert() { - local CERT_FILE="$1" - local CERT_NAME - CERT_NAME=$(basename "$CERT_FILE") - - _STATUS="FAIL" - _SUBJECT="" - _EXPIRY_EPOCH=0 - - echo "" - echo "========================================" - echo "📜 Validating: $CERT_NAME" - echo "========================================" - - if [[ ! -f "$CERT_FILE" ]]; then - echo "::warning file=${CERT_FILE}::Certificate file not found: $CERT_FILE" - echo "❌ Certificate file not found: $CERT_FILE" - return - fi - - if ! CERT_INFO=$(openssl x509 -in "$CERT_FILE" -noout -subject -issuer -dates 2>&1); then - echo "::error file=${CERT_FILE}::Failed to parse certificate: $CERT_FILE" - echo "❌ Failed to parse certificate: $CERT_FILE" - return - fi - - echo "$CERT_INFO" - echo "----------------------------------------" - - NOT_BEFORE=$(echo "$CERT_INFO" | grep "notBefore=" | sed 's/notBefore=//') - NOT_AFTER=$(echo "$CERT_INFO" | grep "notAfter=" | sed 's/notAfter=//') - ISSUER=$(echo "$CERT_INFO" | grep "issuer=" | sed 's/issuer=//') - _SUBJECT=$(echo "$CERT_INFO" | grep "subject=" | sed 's/subject=//') - - NOT_BEFORE_EPOCH=$(convert_cert_date_to_epoch "$NOT_BEFORE") || { return; } - NOT_AFTER_EPOCH=$(convert_cert_date_to_epoch "$NOT_AFTER") || { return; } - CURRENT_EPOCH=$(date +%s) - - _EXPIRY_EPOCH=$NOT_AFTER_EPOCH - DAYS_UNTIL_EXPIRY=$(( (NOT_AFTER_EPOCH - CURRENT_EPOCH) / 86400 )) - - echo "🏛️ Certificate Authority: $ISSUER" - echo "" - - if [[ $CURRENT_EPOCH -lt $NOT_BEFORE_EPOCH ]]; then - echo "::warning file=${CERT_FILE}::Certificate is not yet valid. Valid from: $NOT_BEFORE" - echo "❌ Certificate is not yet valid. Valid from: $NOT_BEFORE" - _STATUS="FAIL" - return - fi - - if [[ $CURRENT_EPOCH -gt $NOT_AFTER_EPOCH ]]; then - echo "::warning file=${CERT_FILE}::Certificate has EXPIRED on: $NOT_AFTER" - echo "❌ Certificate EXPIRED on: $NOT_AFTER" - _STATUS="EXPIRED" - return - fi - - if [[ $DAYS_UNTIL_EXPIRY -le $WARNING_DAYS ]]; then - echo "::warning file=${CERT_FILE}::Certificate will expire in $DAYS_UNTIL_EXPIRY days on $NOT_AFTER" - echo "⚠️ WARNING: Certificate will expire in $DAYS_UNTIL_EXPIRY days on $NOT_AFTER" - _STATUS="WARN" - return - fi - - echo "✅ Certificate is valid. Expires in $DAYS_UNTIL_EXPIRY days on $NOT_AFTER" - _STATUS="OK" - } - - # ------------------------------------------------------- - # Phase 1: Validate every certificate individually - # ------------------------------------------------------- - IDX=0 - for CERT_FILE in "${CERT_FILES[@]}"; do - validate_cert "$CERT_FILE" - - CERT_STATUS+=("$_STATUS") - CERT_SUBJECT+=("$_SUBJECT") - CERT_EXPIRY_EPOCH+=("$_EXPIRY_EPOCH") - CERT_NAME_ARR+=("$(basename "$CERT_FILE")") - - # Group by subject — track the best (latest-expiring, valid) cert per subject - SUBJ="$_SUBJECT" - if [[ -n "$SUBJ" ]]; then - SUBJECT_CERTS["$SUBJ"]="${SUBJECT_CERTS["$SUBJ"]:-} $IDX" - - # Track best status per subject group - PREV_STATUS="${SUBJECT_BEST_STATUS["$SUBJ"]:-NONE}" - PREV_EXPIRY="${SUBJECT_BEST_EXPIRY["$SUBJ"]:-0}" - - # A cert is "better" if it's valid (OK or WARN) and expires later - if [[ "$_STATUS" == "OK" || "$_STATUS" == "WARN" ]]; then - if [[ "$PREV_STATUS" != "OK" || "$_EXPIRY_EPOCH" -gt "$PREV_EXPIRY" ]]; then - SUBJECT_BEST_STATUS["$SUBJ"]="$_STATUS" - SUBJECT_BEST_EXPIRY["$SUBJ"]="$_EXPIRY_EPOCH" - fi - fi - fi - - IDX=$((IDX + 1)) - done - - # ------------------------------------------------------- - # Phase 2: Determine final status per cert considering - # whether a newer valid replacement exists - # ------------------------------------------------------- - for ((i=0; i<${#CERT_STATUS[@]}; i++)); do - STATUS="${CERT_STATUS[$i]}" - CERT_NAME="${CERT_NAME_ARR[$i]}" - SUBJ="${CERT_SUBJECT[$i]}" - GROUP_BEST="${SUBJECT_BEST_STATUS["$SUBJ"]:-NONE}" - - case "$STATUS" in - OK) - SUMMARY_ROWS+=("| \`$CERT_NAME\` | ✅ Valid |") - ;; - WARN) - # If there's a newer valid (OK) cert in the same subject group, downgrade to info - if [[ "$GROUP_BEST" == "OK" && "${SUBJECT_BEST_EXPIRY["$SUBJ"]}" -gt "${CERT_EXPIRY_EPOCH[$i]}" ]]; then - SUMMARY_ROWS+=("| \`$CERT_NAME\` | ⚠️ Expiring Soon (newer replacement exists) |") - else - SUMMARY_ROWS+=("| \`$CERT_NAME\` | ⚠️ Expiring Soon |") - WARN_COUNT=$((WARN_COUNT + 1)) - fi - ;; - EXPIRED) - # If there's a valid replacement in the same subject group, don't fail - if [[ "$GROUP_BEST" == "OK" || "$GROUP_BEST" == "WARN" ]]; then - SUMMARY_ROWS+=("| \`$CERT_NAME\` | ⏳ Expired (newer replacement exists) |") - else - SUMMARY_ROWS+=("| \`$CERT_NAME\` | ❌ Expired |") - FAIL_COUNT=$((FAIL_COUNT + 1)) - fi - ;; - *) - SUMMARY_ROWS+=("| \`$CERT_NAME\` | ❌ Failed |") - FAIL_COUNT=$((FAIL_COUNT + 1)) - ;; - esac - done - - # ------------------------------------------------------- - # Print summary table to stdout - # ------------------------------------------------------- - echo "" - echo "========================================" - echo "📋 Certificate Validation Summary" - echo "========================================" - printf "| %-60s | %-45s |\n" "Certificate" "Status" - printf "|-%s-|-%s-|\n" "$(printf '%0.s-' {1..60})" "$(printf '%0.s-' {1..45})" - for row in "${SUMMARY_ROWS[@]}"; do - echo "$row" - done - echo "" - - # ------------------------------------------------------- - # Write summary to GitHub Step Summary for a nice UI table - # ------------------------------------------------------- - { - echo "## 🔐 Certificate Validation Summary" - echo "" - echo "| Certificate | Status |" - echo "|-------------|--------|" - for row in "${SUMMARY_ROWS[@]}"; do - echo "$row" - done - echo "" - if [[ $FAIL_COUNT -gt 0 ]]; then - echo "> ❌ **$FAIL_COUNT certificate(s) FAILED validation with no valid replacement.**" - elif [[ $WARN_COUNT -gt 0 ]]; then - echo "> ⚠️ **$WARN_COUNT certificate(s) are nearing expiry with no newer replacement.**" - else - echo "> ✅ **All certificate(s) are valid (or have valid replacements).**" - fi - echo "" - echo "| Metric | Count |" - echo "|--------|-------|" - echo "| Failed | $FAIL_COUNT |" - echo "| Warnings | $WARN_COUNT |" - } >> "$GITHUB_STEP_SUMMARY" - - # ------------------------------------------------------- - # Emit outputs for programmatic use - # ------------------------------------------------------- - if [[ $FAIL_COUNT -gt 0 || ( "$FAIL_ON_WARN" == "true" && $WARN_COUNT -gt 0 ) ]]; then - RESULT="fail" - elif [[ $WARN_COUNT -gt 0 ]]; then - RESULT="warn" - else - RESULT="pass" - fi - - echo "result=$RESULT" >> "$GITHUB_OUTPUT" - echo "fail_count=$FAIL_COUNT" >> "$GITHUB_OUTPUT" - echo "warn_count=$WARN_COUNT" >> "$GITHUB_OUTPUT" - - # ------------------------------------------------------- - # Enforce fail_on_warn if requested - # ------------------------------------------------------- - if [[ "$FAIL_ON_WARN" == "true" && $WARN_COUNT -gt 0 ]]; then - echo "❌ fail_on_warn=true: $WARN_COUNT certificate(s) are nearing expiry. Failing the job." - exit 1 - fi - - # ------------------------------------------------------- - # Final exit code - # ------------------------------------------------------- - if [[ $FAIL_COUNT -gt 0 ]]; then - echo "❌ $FAIL_COUNT certificate(s) FAILED with no valid replacement. See details above." - exit 1 - elif [[ $WARN_COUNT -gt 0 ]]; then - echo "⚠️ All certificates valid, but $WARN_COUNT certificate(s) are nearing expiry with no newer replacement." - exit 0 - else - echo "✅ All certificate(s) are valid (or have valid replacements)." - exit 0 - fi + run: bash "${{ github.action_path }}/validate.sh" diff --git a/validate.sh b/validate.sh new file mode 100644 index 0000000..7dc55c1 --- /dev/null +++ b/validate.sh @@ -0,0 +1,321 @@ +#!/usr/bin/env bash +# +# Copyright 2026 ABSA Group Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +set -uo pipefail + +INPUT_CERTIFICATES="${INPUT_CERTIFICATES:?'INPUT_CERTIFICATES environment variable is required'}" +INPUT_WARNING_DAYS="${INPUT_WARNING_DAYS:-30}" +INPUT_FAIL_ON_WARN="${INPUT_FAIL_ON_WARN:-false}" +GITHUB_STEP_SUMMARY="${GITHUB_STEP_SUMMARY:-/dev/null}" +GITHUB_OUTPUT="${GITHUB_OUTPUT:-/dev/null}" + +FAIL_ON_WARN="${INPUT_FAIL_ON_WARN,,}" +if [[ "$FAIL_ON_WARN" != "true" && "$FAIL_ON_WARN" != "false" ]]; then + echo "::error::INPUT_FAIL_ON_WARN must be 'true' or 'false', got: '$INPUT_FAIL_ON_WARN'" + exit 1 +fi + +if [[ ! "$INPUT_WARNING_DAYS" =~ ^[0-9]+$ ]]; then + echo "::error::INPUT_WARNING_DAYS must be a non-negative integer, got: '$INPUT_WARNING_DAYS'" + exit 1 +fi + +if ! echo "$INPUT_CERTIFICATES" | jq -e 'type == "array"' > /dev/null 2>&1; then + echo "::error::INPUT_CERTIFICATES must be a valid JSON array, got: '$INPUT_CERTIFICATES'" + exit 1 +fi + +CERTIFICATES="$INPUT_CERTIFICATES" +WARNING_DAYS=$((10#$INPUT_WARNING_DAYS)) + +# Parse JSON array into a bash array +mapfile -t CERT_FILES < <(echo "$CERTIFICATES" | jq -r '.[]') + +if [[ ${#CERT_FILES[@]} -eq 0 ]]; then + echo "::error::No certificate files provided in the JSON array." + exit 1 +fi + +echo "🔍 Found ${#CERT_FILES[@]} certificate(s) to validate." + +WARN_COUNT=0 +FAIL_COUNT=0 +declare -a SUMMARY_ROWS=() + +# Associative arrays for grouping certs by subject +declare -A SUBJECT_BEST_EXPIRY=() # subject -> best (latest) notAfter epoch +declare -A SUBJECT_BEST_STATUS=() # subject -> best status (OK, WARN, FAIL) +declare -A SUBJECT_CERTS=() # subject -> space-separated list of cert indices +declare -a CERT_STATUS=() # per-cert status +declare -a CERT_SUBJECT=() # per-cert subject +declare -a CERT_EXPIRY_EPOCH=() # per-cert expiry epoch +declare -a CERT_NAME_ARR=() # per-cert basename + +# ------------------------------------------------------- +# Helper: convert OpenSSL date string to epoch +# Format: "Sep 18 08:57:36 2025 GMT" +# ------------------------------------------------------- +convert_cert_date_to_epoch() { + local cert_date="$1" + if [[ $cert_date =~ ^([A-Z][a-z]+)\ +([0-9]+)\ +([0-9]{2}):([0-9]{2}):([0-9]{2})\ +([0-9]{4})\ +GMT$ ]]; then + local month="${BASH_REMATCH[1]}" + local day="${BASH_REMATCH[2]}" + local hour="${BASH_REMATCH[3]}" + local minute="${BASH_REMATCH[4]}" + local second="${BASH_REMATCH[5]}" + local year="${BASH_REMATCH[6]}" + + case "$month" in + Jan) month_num=01 ;; Feb) month_num=02 ;; Mar) month_num=03 ;; + Apr) month_num=04 ;; May) month_num=05 ;; Jun) month_num=06 ;; + Jul) month_num=07 ;; Aug) month_num=08 ;; Sep) month_num=09 ;; + Oct) month_num=10 ;; Nov) month_num=11 ;; Dec) month_num=12 ;; + *) echo "Invalid month: $month" >&2; return 1 ;; + esac + + day=$(printf "%02d" "$day") + + # Works on both Linux (GNU date) and macOS (BSD date) + date -u -d "${year}-${month_num}-${day} ${hour}:${minute}:${second}" +%s 2>/dev/null || \ + date -u -j -f "%Y-%m-%d %H:%M:%S" "${year}-${month_num}-${day} ${hour}:${minute}:${second}" +%s 2>/dev/null + else + echo "Invalid date format: $cert_date" >&2 + return 1 + fi +} + +# ------------------------------------------------------- +# Validate a single certificate and output its info. +# Sets global variables: _STATUS, _SUBJECT, _EXPIRY_EPOCH +# ------------------------------------------------------- +validate_cert() { + local CERT_FILE="$1" + local CERT_NAME + CERT_NAME=$(basename "$CERT_FILE") + + _STATUS="FAIL" + _SUBJECT="" + _EXPIRY_EPOCH=0 + + echo "" + echo "========================================" + echo "📜 Validating: $CERT_NAME" + echo "========================================" + + if [[ ! -f "$CERT_FILE" ]]; then + echo "::warning file=${CERT_FILE}::Certificate file not found: $CERT_FILE" + echo "❌ Certificate file not found: $CERT_FILE" + return + fi + + if ! CERT_INFO=$(openssl x509 -in "$CERT_FILE" -noout -subject -issuer -dates 2>&1); then + echo "::error file=${CERT_FILE}::Failed to parse certificate: $CERT_FILE" + echo "❌ Failed to parse certificate: $CERT_FILE" + return + fi + + echo "$CERT_INFO" + echo "----------------------------------------" + + NOT_BEFORE=$(echo "$CERT_INFO" | grep "notBefore=" | sed 's/notBefore=//') + NOT_AFTER=$(echo "$CERT_INFO" | grep "notAfter=" | sed 's/notAfter=//') + ISSUER=$(echo "$CERT_INFO" | grep "issuer=" | sed 's/issuer=//') + _SUBJECT=$(echo "$CERT_INFO" | grep "subject=" | sed 's/subject=//') + + NOT_BEFORE_EPOCH=$(convert_cert_date_to_epoch "$NOT_BEFORE") || { return; } + NOT_AFTER_EPOCH=$(convert_cert_date_to_epoch "$NOT_AFTER") || { return; } + CURRENT_EPOCH=$(date +%s) + + _EXPIRY_EPOCH=$NOT_AFTER_EPOCH + DAYS_UNTIL_EXPIRY=$(( (NOT_AFTER_EPOCH - CURRENT_EPOCH) / 86400 )) + + echo "🏛️ Certificate Authority: $ISSUER" + echo "" + + if [[ $CURRENT_EPOCH -lt $NOT_BEFORE_EPOCH ]]; then + echo "::warning file=${CERT_FILE}::Certificate is not yet valid. Valid from: $NOT_BEFORE" + echo "❌ Certificate is not yet valid. Valid from: $NOT_BEFORE" + _STATUS="FAIL" + return + fi + + if [[ $CURRENT_EPOCH -gt $NOT_AFTER_EPOCH ]]; then + echo "::warning file=${CERT_FILE}::Certificate has EXPIRED on: $NOT_AFTER" + echo "❌ Certificate EXPIRED on: $NOT_AFTER" + _STATUS="EXPIRED" + return + fi + + if [[ $DAYS_UNTIL_EXPIRY -le $WARNING_DAYS ]]; then + echo "::warning file=${CERT_FILE}::Certificate will expire in $DAYS_UNTIL_EXPIRY days on $NOT_AFTER" + echo "⚠️ WARNING: Certificate will expire in $DAYS_UNTIL_EXPIRY days on $NOT_AFTER" + _STATUS="WARN" + return + fi + + echo "✅ Certificate is valid. Expires in $DAYS_UNTIL_EXPIRY days on $NOT_AFTER" + _STATUS="OK" +} + +# ------------------------------------------------------- +# Phase 1: Validate every certificate individually +# ------------------------------------------------------- +IDX=0 +for CERT_FILE in "${CERT_FILES[@]}"; do + validate_cert "$CERT_FILE" + + CERT_STATUS+=("$_STATUS") + CERT_SUBJECT+=("$_SUBJECT") + CERT_EXPIRY_EPOCH+=("$_EXPIRY_EPOCH") + CERT_NAME_ARR+=("$(basename "$CERT_FILE")") + + # Group by subject — track the best (latest-expiring, valid) cert per subject + SUBJ="$_SUBJECT" + if [[ -n "$SUBJ" ]]; then + SUBJECT_CERTS["$SUBJ"]="${SUBJECT_CERTS["$SUBJ"]:-} $IDX" + + # Track best status per subject group + PREV_STATUS="${SUBJECT_BEST_STATUS["$SUBJ"]:-NONE}" + PREV_EXPIRY="${SUBJECT_BEST_EXPIRY["$SUBJ"]:-0}" + + # A cert is "better" if it's valid (OK or WARN) and expires later + if [[ "$_STATUS" == "OK" || "$_STATUS" == "WARN" ]]; then + if [[ "$PREV_STATUS" != "OK" || "$_EXPIRY_EPOCH" -gt "$PREV_EXPIRY" ]]; then + SUBJECT_BEST_STATUS["$SUBJ"]="$_STATUS" + SUBJECT_BEST_EXPIRY["$SUBJ"]="$_EXPIRY_EPOCH" + fi + fi + fi + + IDX=$((IDX + 1)) +done + +# ------------------------------------------------------- +# Phase 2: Determine final status per cert considering +# whether a newer valid replacement exists +# ------------------------------------------------------- +for ((i=0; i<${#CERT_STATUS[@]}; i++)); do + STATUS="${CERT_STATUS[$i]}" + CERT_NAME="${CERT_NAME_ARR[$i]}" + SUBJ="${CERT_SUBJECT[$i]}" + GROUP_BEST="${SUBJECT_BEST_STATUS["$SUBJ"]:-NONE}" + + case "$STATUS" in + OK) + SUMMARY_ROWS+=("| \`$CERT_NAME\` | ✅ Valid |") + ;; + WARN) + # If there's a newer valid (OK) cert in the same subject group, downgrade to info + if [[ "$GROUP_BEST" == "OK" && "${SUBJECT_BEST_EXPIRY["$SUBJ"]}" -gt "${CERT_EXPIRY_EPOCH[$i]}" ]]; then + SUMMARY_ROWS+=("| \`$CERT_NAME\` | ⚠️ Expiring Soon (newer replacement exists) |") + else + SUMMARY_ROWS+=("| \`$CERT_NAME\` | ⚠️ Expiring Soon |") + WARN_COUNT=$((WARN_COUNT + 1)) + fi + ;; + EXPIRED) + # If there's a valid replacement in the same subject group, don't fail + if [[ "$GROUP_BEST" == "OK" || "$GROUP_BEST" == "WARN" ]]; then + SUMMARY_ROWS+=("| \`$CERT_NAME\` | ⏳ Expired (newer replacement exists) |") + else + SUMMARY_ROWS+=("| \`$CERT_NAME\` | ❌ Expired |") + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi + ;; + *) + SUMMARY_ROWS+=("| \`$CERT_NAME\` | ❌ Failed |") + FAIL_COUNT=$((FAIL_COUNT + 1)) + ;; + esac +done + +# ------------------------------------------------------- +# Print summary table to stdout +# ------------------------------------------------------- +echo "" +echo "========================================" +echo "📋 Certificate Validation Summary" +echo "========================================" +printf "| %-60s | %-45s |\n" "Certificate" "Status" +printf "|-%s-|-%s-|\n" "$(printf '%0.s-' {1..60})" "$(printf '%0.s-' {1..45})" +for row in "${SUMMARY_ROWS[@]}"; do + echo "$row" +done +echo "" + +# ------------------------------------------------------- +# Write summary to GitHub Step Summary for a nice UI table +# ------------------------------------------------------- +{ + echo "## 🔐 Certificate Validation Summary" + echo "" + echo "| Certificate | Status |" + echo "|-------------|--------|" + for row in "${SUMMARY_ROWS[@]}"; do + echo "$row" + done + echo "" + if [[ $FAIL_COUNT -gt 0 ]]; then + echo "> ❌ **$FAIL_COUNT certificate(s) FAILED validation with no valid replacement.**" + elif [[ $WARN_COUNT -gt 0 ]]; then + echo "> ⚠️ **$WARN_COUNT certificate(s) are nearing expiry with no newer replacement.**" + else + echo "> ✅ **All certificate(s) are valid (or have valid replacements).**" + fi + echo "" + echo "| Metric | Count |" + echo "|--------|-------|" + echo "| Failed | $FAIL_COUNT |" + echo "| Warnings | $WARN_COUNT |" +} >> "$GITHUB_STEP_SUMMARY" + +# ------------------------------------------------------- +# Emit outputs for programmatic use +# ------------------------------------------------------- +if [[ $FAIL_COUNT -gt 0 || ( "$FAIL_ON_WARN" == "true" && $WARN_COUNT -gt 0 ) ]]; then + RESULT="fail" +elif [[ $WARN_COUNT -gt 0 ]]; then + RESULT="warn" +else + RESULT="pass" +fi + +echo "result=$RESULT" >> "$GITHUB_OUTPUT" +echo "fail_count=$FAIL_COUNT" >> "$GITHUB_OUTPUT" +echo "warn_count=$WARN_COUNT" >> "$GITHUB_OUTPUT" + +# ------------------------------------------------------- +# Enforce fail_on_warn if requested +# ------------------------------------------------------- +if [[ "$FAIL_ON_WARN" == "true" && $WARN_COUNT -gt 0 ]]; then + echo "❌ fail_on_warn=true: $WARN_COUNT certificate(s) are nearing expiry. Failing the job." + exit 1 +fi + +# ------------------------------------------------------- +# Final exit code +# ------------------------------------------------------- +if [[ $FAIL_COUNT -gt 0 ]]; then + echo "❌ $FAIL_COUNT certificate(s) FAILED with no valid replacement. See details above." + exit 1 +elif [[ $WARN_COUNT -gt 0 ]]; then + echo "⚠️ All certificates valid, but $WARN_COUNT certificate(s) are nearing expiry with no newer replacement." + exit 0 +else + echo "✅ All certificate(s) are valid (or have valid replacements)." + exit 0 +fi