DOC_INDEX
THEME
DOCS/Use Cases/Certificate Expiry Audit

Certificate Expiry Audit

Sweep TLS certificates with openssl, aggregate expiry status in JavaScript, and alert before anything expires

Expired certificates cause outages that are 100% predictable. This audit loops over a certificate inventory, asks openssl whether each one survives the warning window, aggregates the results in JavaScript, writes a report, and fails loudly (with an alert) when anything needs rotation.

Executed for real against OrchStep v0.7.1 with three real self-signed certs of different lifetimes.

What it demonstrates

CapabilityWhere
Loop over an inventorycheck_all over vars.certs
Tolerant per-item failureson_error: ignore keeps the sweep going
Loop outputs -> JS aggregationsummarize reads steps.check_all.outputs
Tunable policy--var warn_days=...
Alert + always-print-reportcatch + finally on the wrapper step

The workflow

name: cert-expiry-audit
desc: "Audit TLS certificates for upcoming expiry and alert when any need rotation"

defaults:
  cert_dir: "./certs"
  warn_days: "30"
  certs:
    - "api.example.com"
    - "web.example.com"
    - "legacy.example.com"

tasks:
  setup_demo_certs:
    desc: "Generate demo certs with different lifetimes (demo helper)"
    steps:
      - name: generate
        loop:
          items: "{{ vars.certs }}"
          as: cn
        func: shell
        do: |
          mkdir -p {{ vars.cert_dir }}
          case "{{ loop.cn }}" in
            legacy*) DAYS=7 ;;     # inside the warning window
            web*)    DAYS=60 ;;
            *)       DAYS=365 ;;
          esac
          openssl req -x509 -newkey rsa:2048 -nodes -days $DAYS \
            -subj "/CN={{ loop.cn }}" \
            -keyout {{ vars.cert_dir }}/{{ loop.cn }}.key \
            -out {{ vars.cert_dir }}/{{ loop.cn }}.pem 2>/dev/null
          echo "generated {{ loop.cn }} ($DAYS days)"

  audit:
    desc: "Check every cert against the warning window; alert if any are close"
    steps:
      - name: run_audit
        task: _audit_inner
        catch:
          - name: alert
            func: shell
            do: 'echo "ALERT: certificates need rotation - see report at ./cert-report.txt"'
        finally:
          - name: show_report
            func: shell
            do: cat ./cert-report.txt

  _audit_inner:
    steps:
      - name: check_all
        desc: "openssl -checkend: exit 0 means still valid after the window"
        loop:
          items: "{{ vars.certs }}"
          as: cn
        func: shell
        do: |
          SECONDS_WINDOW=$(( {{ vars.warn_days }} * 24 * 3600 ))
          EXPIRY=$(openssl x509 -enddate -noout -in {{ vars.cert_dir }}/{{ loop.cn }}.pem | cut -d= -f2)
          if openssl x509 -checkend $SECONDS_WINDOW -noout -in {{ vars.cert_dir }}/{{ loop.cn }}.pem; then
            echo "status=ok cn={{ loop.cn }} expires=$EXPIRY"
          else
            echo "status=expiring cn={{ loop.cn }} expires=$EXPIRY"
          fi
        on_error: ignore
        outputs:
          status: '{{ result.output | regexFind "status=([a-z]+)" }}'
          cn: "{{ loop.cn }}"
          expires: '{{ result.output | regexFind "expires=(.+)" }}'

      - name: summarize
        desc: "Aggregate the loop results in JavaScript"
        func: transform
        do: |
          const rows = steps.check_all.outputs;
          const expiring = rows.filter(r => r.status !== "ok").map(r => r.cn);
          return {
            total: rows.length,
            expiring_count: expiring.length,
            expiring_list: expiring.join(", ") || "none",
          };

      - name: write_report
        func: shell
        do: |
          cat > ./cert-report.txt <<R
          certificate expiry report (warn window: {{ vars.warn_days }} days)
          checked  = {{ steps.summarize.total }}
          expiring = {{ steps.summarize.expiring_count }} ({{ steps.summarize.expiring_list }})
          R
          echo "written"

      - name: gate
        func: assert
        args:
          condition: "steps.summarize.expiring_count === 0"
          desc: "no certificates inside the rotation window"

Run it

orchstep run setup_demo_certs                 # 7d, 60d and 365d certs
orchstep run audit                            # 30-day window: one offender
orchstep run audit --var warn_days=5          # relaxed window: clean

Verified results:

# 30-day window
expiring = 1 (legacy.example.com)
ALERT: certificates need rotation - see report at ./cert-report.txt

# 5-day window
expiring = 0 (none)

Design notes

openssl -checkend does the math. No date parsing: exit code 0 means the cert is still valid after the window. The step converts that into a clean status=ok|expiring output per certificate.

Loop outputs are an array. Each iteration contributes { " }}status, cn, expires{{ " } and the transform filters them - the same shape works for any sweep-then-aggregate audit (disk usage, DNS records, package versions).

Take it to production

Feed vars.certs from your real inventory, point the paths at live cert files (or s_client to remote endpoints), wire the alert to your pager, and schedule the audit daily. Rotation itself can become a second task that the alert tells you to run.