AXLR / Catalog / security

cert_inspect

Inspect a PEM X.509 certificate with openssl: subject, issuer, serial, notBefore and notAfter, SHA-256 fingerprint, subject alternative names, key type, size and curve, signature algorithm, and the days left until expiry relative to a date given in the input (the clock is never read). Flags expired, not-yet-valid, short RSA keys, SHA-1 or MD5 signatures and missing SANs. Bash with coreutils and openssl; no chain or revocation check.

Install

/tools install cert_inspect
/tools install cert_inspect@1.0.0

In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · cert_inspect 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool cert_inspect; each run is approved as a local_exec.

Arguments

One JSON object on stdin, validated against the schema before the program starts.

nametyperequireddefaultmeaning
pemstringyesThe certificate in PEM form (-----BEGIN CERTIFICATE-----); the first block is read. length ≤ 200000
nowstringyesReference date, UTC: YYYY-MM-DD (midnight) or YYYY-MM-DDTHH:MM:SSZ. pattern ^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}Z)?$
Input schema (JSON)
{
  "type": "object",
  "required": [
    "pem",
    "now"
  ],
  "additionalProperties": false,
  "properties": {
    "pem": {
      "type": "string",
      "maxLength": 200000,
      "description": "The certificate in PEM form (-----BEGIN CERTIFICATE-----); the first block is read"
    },
    "now": {
      "type": "string",
      "pattern": "^\\d{4}-\\d{2}-\\d{2}(T\\d{2}:\\d{2}:\\d{2}Z)?$",
      "description": "Reference date, UTC: YYYY-MM-DD (midnight) or YYYY-MM-DDTHH:MM:SSZ"
    }
  }
}

Example

Input

{
  "pem": "-----BEGIN CERTIFICATE-----\nMIIDczCCAlugAwIBAgIDGis8MA0GCSqGSIb3DQEBCwUAMDgxCzAJBgNVBAYTAkVT\nMRIwEAYDVQQKDAlBWExSIFRlc3QxFTATBgNVBAMMDHRlc3QuZXhhbXBsZTAeFw0y\nNjEwMTgwNDI1NDJaFw0yNzEwMTgwNDI1NDJaMDgxCzAJBgNVBAYTAkVTMRIwEAYD\nVQQKDAlBWExSIFRlc3QxFTATBgNVBAMMDHRlc3QuZXhhbXBsZTCCASIwDQYJKoZI\nhvcNAQEBBQADggEPADCCAQoCggEBANvsV+BqHF87NSo7tOW+XSL2z4aGHpFo4c8v\niCKoepFwE3Xryl7mXJLsZiMoZ5aRNwbGn/+clg3C/kg+7/A2NLvq1G7CZYzvdnu5\n3EWjAIOpUVQQ6w73U4qMyWVEpZgIkbb3LzRT3XL1+V7KYZDW4OcN/9hzMZO2QfFu\nABbbCpphHjJJ2+Xygs/pIEP1TqKnNZEAk0GicoEEaw01lkZ4H4r6wX4inwBYlQgv\nqV/nRVR1G01gvz1KGBmImg2Io7pQiYdfLbkWH3s8eYTd02xkhLHw4bqTruT8nIRV\nb1zfe0mPzK7z1FdGDxqO6gXgPk3pYHwNioqK5/ajngPmEOrHlTMCAwEAAaOBhTCB\ngjAdBgNVHQ4EFgQUkdePRF9DNgbaMdvsJuy+YHsZRoQwHwYDVR0jBBgwFoAUkdeP\nRF9DNgbaMdvsJuy+YHsZRoQwDwYDVR0TAQH/BAUwAwEB/zAvBgNVHREEKDAmggx0\nZXN0LmV4YW1wbGWCEHd3dy50ZXN0LmV4YW1wbGWHBH8AAAEwDQYJKoZIhvcNAQEL\nBQADggEBAE5r6X0SglWl6QHUGnSU2aADFM/c4T6HwuDbbrjUraASxKscUMszqnlB\nwBvWXoXjwM+3okAGM8bv3O/BvzOHDAakDDtzRuYYPlHmGA+vrt5WF9Ai3cfgnERy\nBpyzhdnTNYPBc6NzynqV+il8Oo0PpVg2WteG5iK5iAtWqG9iloHvfl3R/5UjM+mR\n6i6MXub1LILLc1uR7f5NvEY7O7joZLEOfw74ENsAZhWPY9t72Jad/t82BXwMVLWT\nTVlWcT2I7AJwMPKdFHLIZzQhTZUUfQsUOQK+QGoIUMk28O9Q2ylAkv7B1yyXhG2K\nWiKJ8npA6wYHlH2fSzTuSUViKTeC+eM=\n-----END CERTIFICATE-----\n",
  "now": "2026-12-01"
}

Output (stdout)

{
  "subject": "CN=test.example,O=AXLR Test,C=ES",
  "serial": "1A2B3C",
  "not_after": "2027-10-18T04:25:42Z",
  "days_to_expiry": 321,
  "subject_alt_names": [
    "DNS:test.example",
    "DNS:www.test.example",
    "IP Address:127.0.0.1"
  ],
  "key": {
    "type": "RSA",
    "bits": 2048,
    "curve": null
  },
  "status": "valid"
}

Files

The command the install card registers is bash cert_inspect.sh, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.

cert_inspect.sh · sha256 f9c4c7a76445ed2d677a96fbf19b93a783cc45c543d40070fb3fb4f2244bb574
#!/usr/bin/env bash
# Inspect a PEM X.509 certificate with openssl: subject, issuer, serial, validity, SHA-256
# fingerprint, subject alternative names, key type and size, and the days left until it
# expires relative to a date given in the input (the clock is never read).
#
# Input (JSON on stdin):
#   pem   the certificate in PEM form, newlines as JSON \n escapes (the first certificate is used)
#   now   "YYYY-MM-DD" or "YYYY-MM-DDTHH:MM:SSZ" (UTC), required for days_to_expiry
# Output: one JSON object. Usage errors print {"error": ...} and exit 2.
set -u
export LC_ALL=C

fail() {
  printf '{"error": %s}\n' "$(json_str "$1")"
  exit 2
}

# JSON string literal: backslashes and quotes escaped, tabs and newlines as escapes,
# other control characters dropped.
json_str() {
  local s=$1
  s=${s//\\/\\\\}
  s=${s//\"/\\\"}
  s=${s//$'\t'/\\t}
  s=${s//$'\r'/}
  s=${s//$'\n'/\\n}
  s=$(printf '%s' "$s" | tr -d '\000-\010\013-\037')
  printf '"%s"' "$s"
}

# The raw (still escaped) value of a top-level string field, or nothing.
field() {
  printf '%s' "$flat" | sed -n -E 's/.*"'"$1"'"[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)".*/\1/p'
}

command -v openssl >/dev/null 2>&1 || fail "openssl is not installed"
input=$(cat)
flat=$(printf '%s' "$input" | tr '\n\r' '  ')
case "$flat" in
  *[![:space:]]*) ;;
  *) fail "stdin is empty; expected a JSON object with pem" ;;
esac
trimmed=$(printf '%s' "$flat" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')
case "$trimmed" in
  \{*\}) ;;
  *) fail "stdin is not a JSON object" ;;
esac

raw_pem=$(field pem)
[ -n "$raw_pem" ] || fail "pem is required: a PEM certificate as a JSON string"
# JSON unescape for the characters a PEM can hold: \n, \r, \/ and \\.
pem=$(printf '%s' "$raw_pem" | sed -e 's/\\r//g' -e 's/\\n/\n/g' -e 's/\\\//\//g' -e 's/\\\\/\\/g')
case "$pem" in
  *"-----BEGIN CERTIFICATE-----"*"-----END CERTIFICATE-----"*) ;;
  *) fail "pem must contain a -----BEGIN CERTIFICATE----- ... -----END CERTIFICATE----- block" ;;
esac
# Keep only the first certificate block.
pem=$(printf '%s\n' "$pem" | sed -n '/-----BEGIN CERTIFICATE-----/,/-----END CERTIFICATE-----/p' | sed '/-----END CERTIFICATE-----/q')

now=$(field now)
if [ -z "$now" ]; then
  if printf '%s' "$flat" | grep -q '"now"[[:space:]]*:'; then
    fail "now must be a string YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ"
  fi
  fail "now is required (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ, UTC); the clock is never read"
fi
if printf '%s' "$now" | grep -Eq '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'; then
  now_text="$now 00:00:00"
elif printf '%s' "$now" | grep -Eq '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$'; then
  now_text=$(printf '%s' "$now" | sed -e 's/T/ /' -e 's/Z$//')
else
  fail "now must be YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ (UTC)"
fi
now_epoch=$(date -u -d "$now_text UTC" +%s 2>/dev/null) || fail "now is not a valid date"
case "$(date -u -d "@$now_epoch" +%Y-%m-%d)" in
  "${now:0:10}") ;;
  *) fail "now is not a valid calendar date" ;;
esac

x509() {
  printf '%s\n' "$pem" | openssl x509 -noout "$@" 2>/dev/null
}

fields=$(x509 -nameopt RFC2253 -subject -issuer -serial -startdate -enddate) ||
  fail "openssl could not parse the certificate (not a valid X.509 PEM)"
value_of() {
  printf '%s\n' "$fields" | sed -n "s/^$1=//p" | head -n 1
}
subject=$(value_of subject)
issuer=$(value_of issuer)
serial=$(value_of serial)
not_before=$(value_of notBefore)
not_after=$(value_of notAfter)
# The fingerprint label differs between openssl versions ("SHA256 Fingerprint", "sha256 Fingerprint").
fingerprint=$(x509 -fingerprint -sha256 | sed -n 's/^[^=]*=//p' | head -n 1)
text=$(x509 -text)
key_algorithm=$(printf '%s\n' "$text" | sed -n 's/^[[:space:]]*Public Key Algorithm:[[:space:]]*//p' | head -n 1)
key_bits=$(printf '%s\n' "$text" | sed -n -E 's/^[[:space:]]*(RSA |EC |DSA )?Public-Key:[[:space:]]*\(([0-9]+) bit\).*/\2/p' | head -n 1)
curve=$(printf '%s\n' "$text" | sed -n 's/^[[:space:]]*NIST CURVE:[[:space:]]*//p' | head -n 1)
[ -n "$curve" ] || curve=$(printf '%s\n' "$text" | sed -n 's/^[[:space:]]*ASN1 OID:[[:space:]]*//p' | head -n 1)
signature_algorithm=$(printf '%s\n' "$text" | sed -n 's/^[[:space:]]*Signature Algorithm:[[:space:]]*//p' | head -n 1)
version=$(printf '%s\n' "$text" | sed -n -E 's/^[[:space:]]*Version:[[:space:]]*([0-9]+).*/\1/p' | head -n 1)
# SANs: the indented line after the extension header; "No extensions" or nothing when absent.
san_line=$(x509 -ext subjectAltName | sed -n '2,$p' | sed -E 's/^[[:space:]]+//' | tr '\n' ' ')
is_ca=false
if printf '%s\n' "$text" | grep -q 'CA:TRUE'; then is_ca=true; fi

case "$key_algorithm" in
  rsaEncryption) key_type=RSA ;;
  id-ecPublicKey) key_type=EC ;;
  ED25519) key_type=Ed25519; key_bits=${key_bits:-256} ;;
  ED448) key_type=Ed448; key_bits=${key_bits:-456} ;;
  dsaEncryption) key_type=DSA ;;
  *) key_type=$key_algorithm ;;
esac

before_epoch=$(date -u -d "$not_before" +%s 2>/dev/null) || fail "could not read notBefore: $not_before"
after_epoch=$(date -u -d "$not_after" +%s 2>/dev/null) || fail "could not read notAfter: $not_after"
iso() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; }
seconds_left=$((after_epoch - now_epoch))
# Whole days, rounded down (floor, also for negative values).
if [ "$seconds_left" -ge 0 ]; then
  days_left=$((seconds_left / 86400))
else
  days_left=$(( -((-seconds_left + 86399) / 86400) ))
fi
validity_days=$(( (after_epoch - before_epoch) / 86400 ))
expired=false; not_yet_valid=false; status=valid
if [ "$now_epoch" -gt "$after_epoch" ]; then expired=true; status=expired; fi
if [ "$now_epoch" -lt "$before_epoch" ]; then not_yet_valid=true; status=not_yet_valid; fi
self_issued=false
[ "$subject" = "$issuer" ] && self_issued=true

sans="["
first=1
if [ -n "$san_line" ] && [ "${san_line#No extensions}" = "$san_line" ]; then
  IFS=',' read -r -a items <<< "$san_line"
  for item in "${items[@]}"; do
    item=$(printf '%s' "$item" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')
    [ -n "$item" ] || continue
    [ $first -eq 1 ] || sans+=", "
    sans+=$(json_str "$item")
    first=0
  done
fi
sans+="]"

warnings="["
add_warning() {
  [ "$warnings" = "[" ] || warnings+=", "
  warnings+=$(json_str "$1")
}
[ "$expired" = true ] && add_warning "the certificate has expired"
[ "$not_yet_valid" = true ] && add_warning "the certificate is not valid yet"
if [ "$status" = valid ] && [ "$days_left" -lt 30 ]; then add_warning "expires in less than 30 days"; fi
if [ "$key_type" = RSA ] && [ -n "$key_bits" ] && [ "$key_bits" -lt 2048 ]; then add_warning "RSA key shorter than 2048 bits"; fi
case "$signature_algorithm" in
  *md5*|*MD5*|sha1With*|ecdsa-with-SHA1) add_warning "weak signature algorithm $signature_algorithm" ;;
esac
[ "$sans" = "[]" ] && add_warning "no subjectAltName: browsers ignore the CN for host names"
warnings+="]"

num_or_null() { if [ -n "$1" ]; then printf '%s' "$1"; else printf 'null'; fi; }
str_or_null() { if [ -n "$1" ]; then json_str "$1"; else printf 'null'; fi; }

printf '{'
printf '"subject": %s, ' "$(json_str "$subject")"
printf '"issuer": %s, ' "$(json_str "$issuer")"
printf '"serial": %s, ' "$(json_str "$serial")"
printf '"version": %s, ' "$(num_or_null "$version")"
printf '"not_before": %s, ' "$(json_str "$(iso "$before_epoch")")"
printf '"not_after": %s, ' "$(json_str "$(iso "$after_epoch")")"
printf '"validity_days": %s, ' "$validity_days"
printf '"now": %s, ' "$(json_str "$(iso "$now_epoch")")"
printf '"days_to_expiry": %s, ' "$days_left"
printf '"status": "%s", "expired": %s, "not_yet_valid": %s, ' "$status" "$expired" "$not_yet_valid"
printf '"sha256_fingerprint": %s, ' "$(json_str "$fingerprint")"
printf '"subject_alt_names": %s, ' "$sans"
printf '"key": {"type": %s, "bits": %s, "curve": %s}, ' "$(str_or_null "$key_type")" "$(num_or_null "$key_bits")" "$(str_or_null "$curve")"
printf '"signature_algorithm": %s, ' "$(str_or_null "$signature_algorithm")"
printf '"self_issued": %s, "ca": %s, ' "$self_issued" "$is_ca"
printf '"warnings": %s, ' "$warnings"
printf '"note": "Fields as read by openssl x509; the signature chain and revocation are not checked."'
printf '}\n'

Where it is served

The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:

curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/cert_inspect/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/cert_inspect/1.0.0/cert_inspect.sh