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
| Capability | Where |
|---|---|
| Loop over an inventory | check_all over vars.certs |
| Tolerant per-item failures | on_error: ignore keeps the sweep going |
| Loop outputs -> JS aggregation | summarize reads steps.check_all.outputs |
| Tunable policy | --var warn_days=... |
| Alert + always-print-report | catch + 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: cleanVerified 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.