Skip to content

Instantly share code, notes, and snippets.

@marfillaster
Created August 11, 2025 14:13
Show Gist options
  • Select an option

  • Save marfillaster/81c8f55d89c7399c6edd63e57d82c91e to your computer and use it in GitHub Desktop.

Select an option

Save marfillaster/81c8f55d89c7399c6edd63e57d82c91e to your computer and use it in GitHub Desktop.

cert-info.sh

cert-info.sh is a lightweight Bash utility for fetching and displaying SSL/TLS certificate details for one or more domains.
It supports both human-readable CSV output and structured JSON output, with options to override IP resolution globally or per-domain.


Features

  • Retrieve SSL/TLS certificate details including:
    • Issuer
    • Serial number
    • Expiration date (UTC, ISO 8601 format)
    • Subject
    • Subject Alternative Names (SANs)
  • Output in:
    • CSV format (default)
    • JSON format (-j)
  • Resolve domains automatically via Google DNS over HTTPS
  • Override IP address resolution:
    • Globally (all domains use same IP)
    • Per-domain
  • Read domains from:
    • Command-line arguments
    • Standard input

Requirements

The following commands must be installed and available in PATH:

  • curl – for DNS resolution via HTTPS
  • jq – for JSON parsing
  • openssl – for retrieving and parsing certificates

Usage

./cert-info.sh [options] domain1 domain2 ...
./cert-info.sh [options] -

Options

Option Description
-j Output results in JSON format
--ip ip[:port] Override IP for all domains (default port 443 if omitted)
--ip domain=ip[:port] Override IP for a specific domain
- Read domain names from standard input

Examples

Check certificate info for two domains in CSV format:

./cert-info.sh example.com github.com

Output in JSON format:

./cert-info.sh -j example.com github.com

Read domains from stdin:

echo "example.com" | ./cert-info.sh -

Read multiple domains from stdin:

cat <<EOF | ./cert-info.sh -
example1.com
example2.com
example3.com
EOF

Use a specific IP and port for all domains:

./cert-info.sh --ip 1.2.3.4:8443 example.com github.com

Override IP for one domain only:

./cert-info.sh --ip example.com=1.2.3.4 github.com example.com

Combine per-domain and global overrides:

./cert-info.sh --ip example.com=1.2.3.4 --ip 2.3.4.5 example.com github.com

Output Format

CSV (default)

domain,"issuer",serial,not_after,san,"subject"

Example:

example.com,"C=US, O=Let's Encrypt, CN=R3",04F8F3A12D35C9B7B9,2025-08-10T14:32:45Z,www.example.com:example.com,"CN=example.com"

JSON (-j)

[
  {
    "domain": "example.com",
    "issuer": "C=US, O=Let's Encrypt, CN=R3",
    "serial": "04F8F3A12D35C9B7B9",
    "not_after": "2025-08-10T14:32:45Z",
    "subject": "CN=example.com",
    "san": "www.example.com:example.com"
  }
]

Notes

  • Default port is 443 unless overridden.
  • On macOS, the script uses date -j -f for parsing certificate expiration dates.
  • Errors (e.g., unresolved domains) are reported in:
    • JSON mode → objects with "error" fields
    • CSV mode → warnings printed to stderr
#!/usr/bin/env bash
# Check for required commands
for cmd in curl jq openssl; do
if ! command -v "$cmd" &> /dev/null; then
echo "Error: '$cmd' is not installed. Please install it." >&2
exit 1
fi
done
JSON_OUTPUT=false
declare -A overrides
GLOBAL_OVERRIDE=""
DOMAINS_FROM_STDIN=false
domains=()
# Parse flags and parameters
while [[ $# -gt 0 ]]; do
case "$1" in
-j)
JSON_OUTPUT=true
shift
;;
--ip)
if [[ -z "$2" ]]; then
echo "Error: --ip requires an argument" >&2
exit 1
fi
# Per-domain override: domain=ip[:port]
if [[ "$2" =~ ^[A-Za-z0-9.-]+=([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(:[0-9]+)?)$ ]]; then
domain_name="${2%%=*}"
ip_value="${2#*=}"
overrides["$domain_name"]="$ip_value"
# Global override: ip[:port]
elif [[ "$2" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(:[0-9]+)?$ ]]; then
GLOBAL_OVERRIDE="$2"
else
echo "Error: Invalid format for --ip. Expected domain=ip[:port] or ip[:port]" >&2
exit 1
fi
shift 2
;;
-)
DOMAINS_FROM_STDIN=true
shift
;;
*)
domains+=("$1")
shift
;;
esac
done
# Read domains from stdin if needed
if [ "$DOMAINS_FROM_STDIN" = true ]; then
while read -r domain; do
[ -n "$domain" ] && domains+=("$domain")
done < /dev/stdin
fi
# Validate input
if [ ${#domains[@]} -eq 0 ]; then
echo "Usage examples:" >&2
echo " cert-info.sh domain1 domain2" >&2
echo " cert-info.sh -j domain1 domain2" >&2
echo " echo domain | cert-info.sh -" >&2
echo " cert-info.sh --ip 1.2.3.4:8443 domain1 domain2" >&2
echo " cert-info.sh --ip example.com=1.2.3.4 domain1" >&2
echo " cert-info.sh --ip example.com=1.2.3.4 --ip 2.3.4.5 domain1 domain2" >&2
exit 1
fi
# Start JSON output if requested
if $JSON_OUTPUT; then
echo "["
fi
first=true
for item in "${domains[@]}"; do
[ -z "$item" ] && continue
# Determine address to connect to
if [ -n "${overrides[$item]}" ]; then
a="${overrides[$item]}"
elif [ -n "$GLOBAL_OVERRIDE" ]; then
a="$GLOBAL_OVERRIDE"
else
a=$(curl -s "https://dns.google/resolve?name=${item}&type=A" |
jq -r '(.Answer // []) | map(select(.type == 1)) | map(.data) | first // empty')
fi
# Default port to 443 if not specified
[[ "$a" != *:* ]] && a="${a}:443"
if [ -z "$a" ]; then
if $JSON_OUTPUT; then
$first || echo ","
echo "{ \"domain\": \"$item\", \"error\": \"No A record found or could not connect.\" }"
first=false
else
echo "Warning: Could not resolve A record for $item. Skipping." >&2
fi
continue
fi
cert_info=$(echo | openssl s_client -servername "$item" -connect "$a" -showcerts 2>/dev/null | \
openssl x509 -noout -issuer -serial -enddate -text -subject -ext subjectAltName | \
awk -v domain_name="$item" '
BEGIN {
is_macos = 0
if (system("uname -s | grep -q Darwin") == 0) {
is_macos = 1
}
}
/^issuer=/ { issuer=substr($0, 8) }
/^serial=/ { serial=substr($0, 8) }
/^notAfter=/ {
raw_date = substr($0,10)
if (is_macos) {
cmd = "date -u -j -f \"%b %e %H:%M:%S %Y %Z\" \"" raw_date "\" +\"%Y-%m-%dT%H:%M:%SZ\""
} else {
cmd = "date -u -d \"" raw_date "\" +\"%Y-%m-%dT%H:%M:%SZ\""
}
while ((cmd | getline not_after) > 0) {}
close(cmd)
}
/^subject=/ { subject=substr($0, 9) }
/ *DNS:/ {
line = $0
gsub(/^ *DNS:/, "", line)
gsub(/,$/, "", line)
split(line, dnsArr, ", ")
temp_san_items = ""
for (i = 1; i in dnsArr; ++i) {
san_item = dnsArr[i]
gsub(/^DNS: */, "", san_item)
gsub(/^ */, "", san_item)
gsub(/ *$/, "", san_item)
if (temp_san_items != "") {
temp_san_items = temp_san_items ":" san_item
} else {
temp_san_items = san_item
}
}
san = temp_san_items
}
END {
printf "{\"domain\":\"%s\",\"issuer\":\"%s\",\"serial\":\"%s\",\"not_after\":\"%s\",\"subject\":\"%s\",\"san\":\"%s\"}\n", \
domain_name, issuer, serial, not_after, subject, san
}'
)
if $JSON_OUTPUT; then
$first || echo ","
echo "$cert_info" | jq .
first=false
else
# Single jq invocation to extract all fields
IFS=$'\t' read -r domain_val issuer_val serial_val not_after_val san_val subject_val < <(
echo "$cert_info" | jq -r '[.domain, .issuer, .serial, .not_after, .san, .subject] | @tsv'
)
echo "$domain_val,\"$issuer_val\",$serial_val,$not_after_val,$san_val,\"$subject_val\""
fi
done
if $JSON_OUTPUT; then
echo "]"
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment