Skip to content

Certificate Expiration (Sensor)

A Tanium sensor that lists the certificates each endpoint uses as its own identity, with the expiration date and the number of days left. One sensor covers Windows, Linux, and macOS. It has no parameters and returns in about a second.

Tanium's Certificate Manager module covers the same ground in more depth. This sensor is for tenants that don't license it, or for a quick look without deploying anything. See Compared with built-in content.


Why use it

An expired certificate causes an outage, not a warning. Services stop accepting connections, and the error rarely says "certificate". Certificates issued through a managed PKI are usually tracked. Certificates added by hand, created by an application installer, or generated by a Linux package usually are not.

Certificate lifetimes are also getting shorter. The CA/Browser Forum lowered the maximum lifetime of public TLS certificates to 200 days on March 15, 2026. It drops to 100 days in March 2027 and 47 days in March 2029. Anything renewed by hand will come due more often.

Scenario What to look for Why it matters
Expiring soon Days Left under 30 Renew before the outage. Common cases: IIS sites, LDAPS on domain controllers, RDP with a CA-issued certificate, nginx or HAProxy, mail servers.
Already expired Days Left below 0 Services that may already be failing, or old certificates left in the store after a renewal.
Where is this certificate installed? The same Thumbprint on several machines A wildcard certificate such as *.example.com is often copied to many servers. When it is renewed or its key is exposed, every copy has to be replaced.
Certificates from an old CA Issuer Before retiring an internal CA, find every machine still using certificates it issued. In testing, this turned up a domain controller certificate from a retired CA with 23 days left.
Machine authentication Rows from LocalMachine\My and LocalMachine\SMS 802.1X, VPN, and ConfigMgr client certificates. When these expire, the machine drops off the network or stops reporting.
First look at a new environment Every row A certificate inventory in minutes, with nothing deployed and nothing changed on the endpoints.

When not to use it

  • The tenant licenses Certificate Manager. Use that instead. It also reads certificates from listening ports and Java keystores.
  • You only need to check one known certificate and the tenant has Incident Response content imported. The Certificate Search sensor already looks up certificates by SHA-1 hash or issuer.
  • You need certificates on network devices, load balancers, or external websites. This sensor only sees the endpoint it runs on.

What it returns

Sample results from a lab (thumbprints shortened):

Subject Issuer Expiration Date Days Left Thumbprint Store or Path
CN=old.example.com CN=old.example.com 2026-09-01 00:00:00 -24 70DD605C... /etc/nginx/ssl/old.crt
CN=dc01.example.com CN=Example-Old-CA, DC=example, DC=com 2026-10-18 00:29:26 23 187A8D8F... LocalMachine\My
CN=app-01.example.com CN=SMS Issuing 2026-10-21 18:23:58 26 7AE6B2D7... LocalMachine\SMS
CN=wiki.example.com CN=YE1, O=Let's Encrypt, C=US 2026-12-06 07:56:36 72 25B036D3... /etc/letsencrypt/live/wiki.example.com/cert.pem
CN=mail.example.com CN=YR2, O=Let's Encrypt, C=US 2026-12-23 18:53:47 89 A758C9E9... /opt/mailcow-dockerized/data/assets/ssl/cert.pem
SAN: dc01.example.com CN=Example-CA, DC=example, DC=com 2027-01-24 07:49:28 121 01862315... LocalMachine\My
CN=web-01.example.com CN=web-01.example.com 2027-02-14 14:35:56 142 11407B64... LocalMachine\Remote Desktop
CN=pve-01.example.com, O=Proxmox Virtual Environment O=PVE Cluster Manager CA, CN=Proxmox Virtual Environment 2027-04-21 02:52:44 208 6BBDB0F1... /etc/pve/nodes/pve-01/pve-ssl.pem

Rows are sorted by Days Left, lowest first.

Output columns

Column Type Contents
Subject Text The certificate's subject, CN first. If the subject is empty, the first DNS name from the Subject Alternative Name is shown as SAN: <name>. If there is no DNS name either, (no subject).
Issuer Text The issuing CA. Self-signed certificates show their own subject.
Expiration Date Date/Time (RFC822) yyyy-MM-dd HH:mm:ss in UTC, so every endpoint uses the same format and time zone.
Days Left Integer Whole days until expiration, rounded down. Negative means expired: -1 expired within the last 24 hours.
Thumbprint Text SHA-1, uppercase, no colons. The same certificate has the same thumbprint on every platform.
Store or Path Text The Windows store (LocalMachine\My), the Linux file path, or the macOS keychain path.

Status results

When there is nothing to list, the text appears in the Subject column and the other columns are empty.

Result Meaning
None No certificates found in the stores or folders this sensor reads.
Error: openssl not found Linux or macOS endpoint without openssl.
Error: Cannot read System keychain macOS could not export the System keychain.
Error: Cannot open store <name> A Windows store exists but could not be opened. The other stores still report.
Error: File limit reached, results incomplete A Linux endpoint had more than 500 candidate files. The rows found are still returned. This usually means a large folder was added to EXTRA_PATHS.
Error: Time limit reached, results incomplete A Linux endpoint took more than 20 seconds reading certificate files. The script stops and returns the rows found so far, so Tanium never times out with no answer.
Error: Unexpected failure The Windows script failed for a reason it did not expect.

Parameters

None. The sensor returns every certificate it finds, and you filter the results in Interact. With no parameter, each endpoint keeps one cached result instead of one per parameter value.


Requirements

  • Windows: PowerShell 5.1.
  • Linux: openssl. The SAN: fallback for certificates with an empty subject needs OpenSSL 1.1.1 or later. Older versions show (no subject) instead.
  • macOS: the built-in security and openssl commands.
  • A Tanium account with permission to create sensors.

Run time in the lab was about 0.8 seconds on Windows Server, 0.1 to 0.2 seconds on Linux, and 3 seconds on a busy Docker host running a mail server.


What it reads

Windows

These LocalMachine stores, opened read-only. Stores that don't exist on a machine are skipped.

Store Typical contents
My (Personal) Server authentication, domain controller, and client authentication certificates
WebHosting IIS site certificates
Remote Desktop The RDP listener certificate
SMS ConfigMgr client and site system certificates
WindowsServerUpdateServices WSUS signing certificate

Skipped on purpose:

  • Trust stores such as Root, AuthRoot, CA, and Disallowed. They hold hundreds of Microsoft-managed CA certificates, some expired by design, and would bury the certificates that matter.
  • MS-Organization-P2P-Access certificates. Windows issues these with a one-day lifetime on Entra-joined and hybrid-joined machines and renews them automatically. They would always show as expiring.

Linux

Linux has no machine certificate store, so the script reads certificate files from three places.

1. Common service folders:

/etc/ssl  /etc/pki/tls  /etc/nginx  /etc/httpd  /etc/apache2  /etc/haproxy
/etc/postfix  /etc/dovecot  /etc/cockpit/ws-certs.d  /etc/pve  /etc/letsencrypt

2. Certificate folders mounted into containers. On Docker and Podman hosts, the script asks the container engine which host folders are mounted into running containers. It adds a folder when its name contains ssl, cert, tls, acme, or letsencrypt, for example /opt/mailcow-dockerized/data/assets/ssl. Only the folder's own name is matched, not its parent folders. The query is read-only and gives up after 5 seconds if the engine does not answer.

3. EXTRA_PATHS, for anything the first two miss. See Find the folder for EXTRA_PATHS.

Rules for every folder:

  • Up to 4 folders deep, files named *.crt, *.pem, *.cer, *.cert, or *.der, under 64 KB. At most 500 files and 20 seconds per run.
  • A Let's Encrypt layout (live/<name>/cert.pem) inside any scanned folder is read. Those files are symlinks, which the folder scan would otherwise skip.
  • Skipped on purpose:
    • The system trust store: symlinks (such as the CA links in /etc/ssl/certs on Debian), CA bundles such as ca-certificates.crt and ca-bundle.crt, and anything under ca-trust, nssdb, or java.
    • Old copies in folders named backup, backups, or archive. Mailcow keeps every previous certificate in ssl/backups/, which added more than 100 expired rows on one lab server and pushed the run time to 28 seconds.
    • Sample certificates in folders such as ssl-example or samples.
  • Private key files are read but fail to parse as certificates, so they are skipped. Key material is never returned.

Find the folder for EXTRA_PATHS

Use this when a Linux server serves HTTPS or mail over TLS but the sensor doesn't show that certificate. Run it once as root on the server. It asks each local port for the certificate it presents, then searches the disk for the file with the same thumbprint. It only reads, but it scans the whole disk once, so allow up to a minute.

# 1. Local ports that present a TLS certificate, with its SHA-1 thumbprint
ports=$(for p in $(ss -tln | awk 'NR>1 {sub(/.*:/, "", $4); print $4}' | sort -un); do
  fp=$(echo | timeout 3 openssl s_client -connect 127.0.0.1:$p 2>/dev/null |
       openssl x509 -noout -fingerprint -sha1 2>/dev/null | sed 's/.*=//; s/://g')
  [ -n "$fp" ] && echo "$p $fp"
done)
echo "== Ports and thumbprints"; echo "$ports"

# 2. Files on disk holding those same certificates
fps=$(echo "$ports" | awk '{print $2}' | sort -u | paste -sd'|')
echo "== Matching files"
find / -xdev -type f \( -name '*.pem' -o -name '*.crt' -o -name '*.cer' -o -name '*.cert' \) -size -64k 2>/dev/null |
while read -r f; do
  fp=$(openssl x509 -in "$f" -noout -fingerprint -sha1 2>/dev/null | sed 's/.*=//; s/://g')
  [ -n "$fp" ] && echo "$fp" | grep -qE "^($fps)$" && echo "$fp $f"
done

# 3. Docker containers with certificate-looking folders mounted
echo "== Docker mounts"
command -v docker >/dev/null && docker ps -q | xargs -r docker inspect -f '{{.Name}}: {{range .Mounts}}{{.Source}} {{end}}' | grep -iE 'ssl|cert|tls|acme'

How to read the output:

  • A matching file: add its folder to EXTRA_PATHS, unless the sensor already reads that folder.
  • A port with no matching file: the application keeps the certificate somewhere that isn't a certificate file, such as its database. No EXTRA_PATHS entry will find it. See Things to know.
  • Ports that need STARTTLS (25, 587, 143, 110) are not listed. Ports that start with TLS directly, such as 443, 465, 993, and 995, are.

Set EXTRA_PATHS at the top of the Linux script in that tenant's copy of the sensor. Separate paths with spaces. Paths cannot contain spaces, and paths that don't exist on a machine are skipped, so one list can cover every server in the tenant.

EXTRA_PATHS="/srv/app/certs /opt/portal/tls"

macOS

The System keychain (/Library/Keychains/System.keychain). Apple's trust roots are in a separate keychain and are not read.


Create the sensor

Menu labels can differ slightly between Tanium versions.

  1. In the Tanium Console, go to Administration > Content > Sensors.
  2. Click Create Sensor.
  3. Fill in the general fields:
    • Sensor Name: Certificate Expiration (add your team's prefix if you use one).
    • Description: Machine certificates with expiration date and days left. Trust stores are skipped.
    • Content Set: the content set your team uses for custom content.
    • Category: Security or your team's custom category.
    • Result Type: Text.
    • Max Sensor Age: 60 minutes.
  4. Enable Split into multiple columns, set Delimiter to |, and add six columns:

    # Column Name Result Type
    1 Subject Text
    2 Issuer Text
    3 Expiration Date Date/Time (RFC822)
    4 Days Left Integer
    5 Thumbprint Text
    6 Store or Path Text

    Use Integer for Days Left

    With Numeric, a filter such as "Days Left less than 30" compared the values as text in testing, so 152 matched because 1 sorts before 3. Integer compares them as numbers.

  5. Add one script per platform:

    • Windows tab: Script Type PowerShell, then paste the Windows script from the bottom of this page.
    • Linux tab: Script Type UnixShell, then paste the Linux script.
    • Mac tab: Script Type UnixShell, then paste the macOS script.

    A platform tab left empty makes those endpoints return Not available on Linux (or the matching platform name).

  6. Set Timeout to 30 seconds.

  7. Click Save.

Ask a question

Get Computer Name and Certificate Expiration from all machines

Each machine appears once, with its certificates listed together.

Get Certificate Expiration from all machines

Without Computer Name, each certificate is one row, and Count shows how many machines have it. A Count above 1 means the same certificate is on several machines. That is either a shared certificate, such as a wildcard, or VMs and containers cloned from one template that all carry the same self-signed certificate.


Troubleshooting

What you see Likely cause What to do
Not available on Linux (or Mac) No script on that platform's tab Edit the sensor and add the script for that platform.
A Linux server returns None or only ssl-cert-snakeoil.pem, but it serves HTTPS The certificate lives in a folder the sensor doesn't read, or isn't a file at all Run the commands in Find the folder for EXTRA_PATHS on that server once.
Error: Time limit reached, results incomplete A folder with many certificate files, usually one added to EXTRA_PATHS Point EXTRA_PATHS at the folder that holds the current certificate, not its parent.
Error: openssl not found Minimal Linux install Install openssl, or accept that this endpoint is not covered.
A renewed certificate still shows the old date Results are reused for up to the Max Sensor Age (60 minutes) Ask again after the cached result expires.
A machine is missing from the results It is offline or has not answered yet Compare with Get Computer Name from all machines.

Compared with built-in content

Checked in a lab tenant before building this sensor. No core Tanium sensor reports certificate expiration.

Certificate Expiry (Certificate Manager) This sensor
Dependencies Tanium Python and the Certificate Audit package. Machines without them return an error. None. PowerShell 5.1, or sh and openssl.
Identifies the certificate No. Only the port, file, or store it came from. Subject, Issuer, and Thumbprint
Trust stores Included. Every CA root on Windows, and every file in /etc/ssl/certs plus each entry in ca-certificates.crt on Linux. Skipped
Expired certificates Days shows 0 Negative days, for example -24
Certificates on listening ports Yes, including services in Docker No. Files and stores, plus certificate folders mounted into Docker or Podman containers.

Certificate Search (Incident Response content) searches installed certificates by issuer name or SHA-1 hash. It is useful for looking up one certificate but is not part of every tenant.


Things to know

  • Read-only. Windows opens each store read-only. Linux and macOS only read files and export certificates to the script's output. Nothing is written on the endpoint.
  • Certificates that aren't certificate files on the host are not seen. Some applications keep certificates in their own database (Authentik in the lab), inside the container image, or in a named Docker volume. The sensor reads host folders only. For those, use Certificate Manager, which also checks what each listening port serves.
  • Java keystores and PKCS#12 files (.jks, .pfx, .p12) are not read. They usually need a password.
  • Only the first certificate in a PEM file is read. In a full-chain file, that is the server certificate.
  • Match certificates across platforms on Thumbprint, not Subject. Windows and Linux write commas inside a name differently: O="Example, Inc." on Windows and O=Example\, Inc. on Linux.
  • Some certificates have an empty subject. Domain controller Kerberos certificates put the name only in the Subject Alternative Name. The sensor shows these as SAN: dc01.example.com.
  • Old certificates stay in the store after renewal. They show up with negative Days Left until someone removes them.
  • ConfigMgr site servers carry self-signed certificates valid for 100 years. Days Left above 36,000 is normal on those machines.
  • The CSV export writes negative numbers with a leading apostrophe, for example '-2, so spreadsheet apps don't treat them as formulas.
  • The macOS script has not been tested on a Mac yet. Its parsing is shared with the Linux script, which has been tested.

Scripts

# Tanium sensor: Certificate Expiration (Windows)
#
# Sensor settings
#   Script type : PowerShell
#   Parameters  : none
#   Result type : Text, split into columns, delimiter |
#   Columns     : Subject | Issuer | Expiration Date | Days Left | Thumbprint | Store or Path
#                 (Days Left = Integer, Expiration Date = Date/Time (RFC822), others = Text)
#
# Reads the LocalMachine stores that hold the machine's own certificates.
# Trust stores (Root, AuthRoot, CA, Disallowed) are skipped on purpose: they hold
# hundreds of Microsoft-managed certificates, some of them expired by design.
# Also skipped: MS-Organization-P2P-Access certificates. Windows issues these with a
# one-day lifetime and renews them automatically, so they would always show as expiring.
# Read-only: every store is opened with ReadOnly and OpenExistingOnly.

$stores     = 'My', 'WebHosting', 'Remote Desktop', 'SMS', 'WindowsServerUpdateServices'
$skipIssuer = 'CN=MS-Organization-P2P-Access*'

$flags       = [Security.Cryptography.X509Certificates.OpenFlags]'ReadOnly, OpenExistingOnly'
$notFound    = -2147024894   # 0x80070002, store does not exist on this machine
$now         = [DateTime]::UtcNow
$rows        = New-Object 'System.Collections.Generic.List[object]'
$errors      = New-Object 'System.Collections.Generic.List[string]'

function Clean([string]$text) {
    if ($text) { $text.Replace('|', '/').Trim() } else { '' }
}

# Some certificates (domain controller Kerberos certificates, for example) have an empty
# Subject and carry the name only in the Subject Alternative Name.
function Get-SubjectText($cert) {
    $subject = Clean $cert.Subject
    if ($subject) { return $subject }
    $dns = Clean (Get-FirstDnsName $cert)
    if ($dns) { "SAN: $dns" } else { '(no subject)' }
}

# First DNS name in the Subject Alternative Name, read from the raw extension bytes.
# Other entry types (such as a domain controller's DS Object GUID) are skipped, and
# nothing depends on the localized text Windows uses to display the extension.
function Get-FirstDnsName($cert) {
    $ext = $cert.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.17' } | Select-Object -First 1
    if (-not $ext) { return '' }
    try {
        $b = $ext.RawData
        $i = 2
        if ($b[1] -band 0x80) { $i += $b[1] -band 0x7F }
        while ($i -lt $b.Length) {
            $tag = $b[$i]
            $len = [int]$b[$i + 1]
            $i += 2
            if ($len -band 0x80) {
                $n = $len -band 0x7F
                $len = 0
                for ($k = 0; $k -lt $n; $k++) { $len = $len * 256 + $b[$i + $k] }
                $i += $n
            }
            if ($tag -eq 0x82) { return [Text.Encoding]::ASCII.GetString($b, $i, $len) }
            $i += $len
        }
    } catch { }
    ''
}

try {
    foreach ($name in $stores) {
        $store = New-Object Security.Cryptography.X509Certificates.X509Store($name, 'LocalMachine')
        try {
            $store.Open($flags)
        } catch {
            $e = $_.Exception
            while ($e.InnerException) { $e = $e.InnerException }
            if ($e.HResult -ne $notFound) { $errors.Add("Error: Cannot open store $name|||||") }
            continue
        }

        try {
            foreach ($c in $store.Certificates) {
                if ($c.Issuer -like $skipIssuer) { continue }
                $end  = $c.NotAfter.ToUniversalTime()
                $days = [int][Math]::Floor(($end - $now).TotalDays)
                $line = '{0}|{1}|{2:yyyy-MM-dd HH:mm:ss}|{3}|{4}|LocalMachine\{5}' -f
                        (Get-SubjectText $c), (Clean $c.Issuer), $end, $days, $c.Thumbprint.ToUpper(), $name
                $rows.Add([pscustomobject]@{ Days = $days; Line = $line })
            }
        } finally {
            $store.Close()
        }
    }
} catch {
    Write-Output 'Error: Unexpected failure|||||'
    exit
}

$rows | Sort-Object Days | ForEach-Object { $_.Line }
$errors | Write-Output

if ($rows.Count -eq 0 -and $errors.Count -eq 0) { Write-Output 'None|||||' }
#!/bin/sh
# Tanium sensor: Certificate Expiration (Linux)
#
# Sensor settings
#   Script type : UnixShell
#   Parameters  : none
#   Result type : Text, split into columns, delimiter |
#   Columns     : Subject | Issuer | Expiration Date | Days Left | Thumbprint | Store or Path
#                 (Days Left = Integer, Expiration Date = Date/Time (RFC822), others = Text)
#
# Linux has no machine certificate store, so this reads certificate files in common
# service locations, plus certificate folders mounted into Docker or Podman containers.
# It does not scan the whole disk. Skipped on purpose: system trust stores (CA bundles,
# ca-trust, NSS databases, Java cacerts), old copies in backup and archive folders,
# and sample certificates.
# Read-only: files are only read by openssl. Nothing is written.

# Extra folders or files to scan, separated by spaces. Leave blank in the library copy.
# Add client-specific locations only in that client's copy. Paths cannot contain spaces.
EXTRA_PATHS=""

SCAN_PATHS="/etc/ssl /etc/pki/tls /etc/nginx /etc/httpd /etc/apache2 /etc/haproxy
/etc/postfix /etc/dovecot /etc/cockpit/ws-certs.d /etc/pve /etc/letsencrypt $EXTRA_PATHS"
MAX_FILES=500
TIME_LIMIT=20   # seconds; keep below the sensor timeout
SKIP='/ca-trust/|/nssdb/|/java/|/backups?/|/archive/|/([^/]*[-_.])?(examples?|samples?)/|/(ca-certificates\.crt|ca-bundle[^/]*\.crt|tls-ca-bundle\.pem|email-ca-bundle\.pem|objsign-ca-bundle\.pem)$'

if ! command -v openssl >/dev/null 2>&1; then
    echo "Error: openssl not found|||||"
    exit 0
fi

# Container hosts: add host folders mounted into running Docker or Podman containers
# when the folder name looks like a certificate folder (ssl, cert, tls, acme,
# letsencrypt). Only the last part of the path is matched, so a parent folder's name
# does not pull in unrelated mounts. This is a read-only query to the local engine,
# limited to 5 seconds in case it hangs.
run_limited() {
    if command -v timeout >/dev/null 2>&1; then timeout 5 "$@"; else "$@"; fi
}
container_paths() {
    for engine in docker podman; do
        command -v "$engine" >/dev/null 2>&1 || continue
        ids=$(run_limited "$engine" ps -q 2>/dev/null) || continue
        [ -n "$ids" ] || continue
        run_limited "$engine" inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' $ids 2>/dev/null
    done | awk -F/ 'tolower($NF) ~ /ssl|cert|tls|acme|letsencrypt/' | sort -u
}
SCAN_PATHS="$SCAN_PATHS $(container_paths)"

tab=$(printf '\t')
us=$(printf '\037')
cr=$(printf '\r')
now=$(date -u +%s)
# RFC 2253 names with ", " between parts, so Subject and Issuer read like Windows (CN first).
NAMEOPT="esc_2253,esc_ctrl,esc_msb,utf8,dump_nostr,dump_unknown,dump_der,sep_comma_plus_space,dn_rev,sname"
OPTS="-noout -subject -issuer -enddate -fingerprint -sha1 -nameopt $NAMEOPT"

list_files() {
    for p in $SCAN_PATHS; do
        if [ -d "$p" ]; then
            find "$p" -maxdepth 4 -type f -size -64k \( -name '*.crt' -o -name '*.pem' \
                -o -name '*.cer' -o -name '*.cert' -o -name '*.der' \) 2>/dev/null
            # Let's Encrypt layout (certbot, and containers that mount it): the current
            # certificate is a symlink under live/, which find -type f skips.
            for f in "$p"/live/*/cert.pem; do
                [ -f "$f" ] && echo "$f"
            done
        elif [ -f "$p" ]; then
            echo "$p"
        fi
    done
}

files=$(list_files | grep -Ev "$SKIP" | sort -u)
count=$(printf '%s\n' "$files" | grep -c .)

# Pass 1: one openssl call per file. Prints path, subject, issuer, notAfter, and
# fingerprint separated by \037. Stops early if the time limit is reached.
raw=$(printf '%s\n' "$files" | head -n "$MAX_FILES" | {
    n=0
    while IFS= read -r f; do
        [ -n "$f" ] || continue
        n=$((n + 1))
        if [ $((n % 25)) -eq 0 ] && [ $(($(date -u +%s) - now)) -ge "$TIME_LIMIT" ]; then
            echo "TIME_LIMIT"
            break
        fi

        inform=""
        info=$(openssl x509 -in "$f" $OPTS 2>/dev/null) ||
            { inform="-inform DER"; info=$(openssl x509 -in "$f" $inform $OPTS 2>/dev/null); } || continue

        s=""; i=""; e=""; fp=""
        while IFS= read -r line; do
            line=${line%"$cr"}
            case $line in
                subject=*)      s=${line#subject=} ;;
                issuer=*)       i=${line#issuer=} ;;
                notAfter=*)     e=${line#notAfter=} ;;
                *Fingerprint=*) fp=${line#*Fingerprint=} ;;
            esac
        done <<EOF
$info
EOF

        # Empty Subject: fall back to the first DNS name in the Subject Alternative Name
        # (needs OpenSSL 1.1.1+).
        if [ -z "${s# }" ]; then
            san=$(openssl x509 -in "$f" $inform -noout -ext subjectAltName 2>/dev/null |
                  awk 'NR == 2 { n = split($0, a, ", "); for (j = 1; j <= n; j++) { x = a[j]; sub(/^ +/, "", x)
                                    if (x ~ /^DNS:/) { sub(/^DNS:/, "", x); print x; exit } } }')
            if [ -n "$san" ]; then s="SAN: $san"; else s="(no subject)"; fi
        fi

        printf '%s%s%s%s%s%s%s%s%s\n' "$f" "$us" "$s" "$us" "$i" "$us" "$e" "$us" "$fp"
    done
})

# Pass 2: one awk call formats every row. notAfter looks like "Jan  1 00:00:00 2027 GMT"
# and is already UTC, so the date is converted with plain arithmetic (no date command).
printf '%s\n' "$raw" | awk -F "$us" -v now="$now" -v count="$count" -v max="$MAX_FILES" '
    function days_from_civil(y, m, d,    era, yoe, doy, doe) {
        y -= (m <= 2)
        era = int(y / 400)
        yoe = y - era * 400
        doy = int((153 * (m > 2 ? m - 3 : m + 9) + 2) / 5) + d - 1
        doe = yoe * 365 + int(yoe / 4) - int(yoe / 100) + doy
        return era * 146097 + doe - 719468
    }
    $0 == "TIME_LIMIT" { timed = 1; next }
    NF < 5 { next }
    {
        path = $1; s = $2; i = $3; e = $4; fp = $5
        sub(/^ +/, "", s); sub(/^ +/, "", i)
        gsub(/\|/, "/", s); gsub(/\|/, "/", i); gsub(/\|/, "/", path)
        gsub(/:/, "", fp); fp = toupper(fp)

        split(e, a, " ")
        mon = (index("JanFebMarAprMayJunJulAugSepOctNovDec", a[1]) + 2) / 3
        split(a[3], t, ":")
        if (mon == int(mon) && mon >= 1 && a[4] > 0) {
            epoch = days_from_civil(a[4], mon, a[2]) * 86400 + t[1] * 3600 + t[2] * 60 + t[3]
            diff = epoch - now
            days = diff >= 0 ? int(diff / 86400) : -int((-diff + 86399) / 86400)
            expires = sprintf("%04d-%02d-%02d %02d:%02d:%02d", a[4], mon, a[2], t[1], t[2], t[3])
            key = days
        } else {
            days = "Unknown"; expires = e; key = 999999
        }
        printf "%s\t%s|%s|%s|%s|%s|%s\n", key, s, i, expires, days, fp, path
        rows++
    }
    END {
        if (timed)       printf "9999998\tError: Time limit reached, results incomplete|||||\n"
        if (count > max) printf "9999999\tError: File limit reached, results incomplete|||||\n"
        if (!rows && !timed && count <= max) printf "0\tNone|||||\n"
    }' | sort -t "$tab" -k1,1n | cut -f2-
exit 0
#!/bin/sh
# Tanium sensor: Certificate Expiration (macOS)
#
# NOT YET TESTED ON A MAC. Parsing logic is shared with the Linux script, which is tested.
#
# Sensor settings
#   Script type : UnixShell
#   Parameters  : none
#   Result type : Text, split into columns, delimiter |
#   Columns     : Subject | Issuer | Expiration Date | Days Left | Thumbprint | Store or Path
#                 (Days Left = Integer, Expiration Date = Date/Time (RFC822), others = Text)
#
# Reads the System keychain. Apple's trust roots (SystemRootCertificates.keychain) are
# skipped on purpose. Read-only: certificates are exported to stdout only, no temp files.

KEYCHAIN=/Library/Keychains/System.keychain

if ! command -v openssl >/dev/null 2>&1; then
    echo "Error: openssl not found|||||"
    exit 0
fi

if ! pems=$(security find-certificate -a -p "$KEYCHAIN" 2>/dev/null); then
    echo "Error: Cannot read System keychain|||||"
    exit 0
fi

tab=$(printf '\t')
us=$(printf '\037')
cr=$(printf '\r')
now=$(date -u +%s)
# RFC 2253 names with ", " between parts, so Subject and Issuer read like Windows (CN first).
NAMEOPT="esc_2253,esc_ctrl,esc_msb,utf8,dump_nostr,dump_unknown,dump_der,sep_comma_plus_space,dn_rev,sname"

# Pass 1: one openssl call per certificate (read from stdin). Prints path, subject,
# issuer, notAfter, and fingerprint separated by \037.
parse_cert() {
    cert=$(cat)
    info=$(printf '%s\n' "$cert" | openssl x509 -noout -subject -issuer -enddate -fingerprint -sha1 -nameopt "$NAMEOPT" 2>/dev/null) || return 0

    s=""; i=""; e=""; fp=""
    while IFS= read -r line; do
        line=${line%"$cr"}
        case $line in
            subject=*)      s=${line#subject=} ;;
            issuer=*)       i=${line#issuer=} ;;
            notAfter=*)     e=${line#notAfter=} ;;
            *Fingerprint=*) fp=${line#*Fingerprint=} ;;
        esac
    done <<EOF
$info
EOF

    # Empty Subject: fall back to the first DNS name in the Subject Alternative Name.
    if [ -z "${s# }" ]; then
        san=$(printf '%s\n' "$cert" | openssl x509 -noout -ext subjectAltName 2>/dev/null |
              awk 'NR == 2 { n = split($0, a, ", "); for (j = 1; j <= n; j++) { x = a[j]; sub(/^ +/, "", x)
                                if (x ~ /^DNS:/) { sub(/^DNS:/, "", x); print x; exit } } }')
        if [ -n "$san" ]; then s="SAN: $san"; else s="(no subject)"; fi
    fi

    printf '%s%s%s%s%s%s%s%s%s\n' "$KEYCHAIN" "$us" "$s" "$us" "$i" "$us" "$e" "$us" "$fp"
}

raw=$(printf '%s\n' "$pems" | while IFS= read -r line; do
    case "$line" in
        *"BEGIN CERTIFICATE"*) pem=$line ;;
        *"END CERTIFICATE"*)   printf '%s\n%s\n' "$pem" "$line" | parse_cert; pem= ;;
        *)                     [ -n "$pem" ] && pem="$pem
$line" ;;
    esac
done)

# Pass 2: one awk call formats every row. notAfter looks like "Jan  1 00:00:00 2027 GMT"
# and is already UTC, so the date is converted with plain arithmetic (no date command).
printf '%s\n' "$raw" | awk -F "$us" -v now="$now" '
    function days_from_civil(y, m, d,    era, yoe, doy, doe) {
        y -= (m <= 2)
        era = int(y / 400)
        yoe = y - era * 400
        doy = int((153 * (m > 2 ? m - 3 : m + 9) + 2) / 5) + d - 1
        doe = yoe * 365 + int(yoe / 4) - int(yoe / 100) + doy
        return era * 146097 + doe - 719468
    }
    NF < 5 { next }
    {
        path = $1; s = $2; i = $3; e = $4; fp = $5
        sub(/^ +/, "", s); sub(/^ +/, "", i)
        gsub(/\|/, "/", s); gsub(/\|/, "/", i); gsub(/\|/, "/", path)
        gsub(/:/, "", fp); fp = toupper(fp)

        split(e, a, " ")
        mon = (index("JanFebMarAprMayJunJulAugSepOctNovDec", a[1]) + 2) / 3
        split(a[3], t, ":")
        if (mon == int(mon) && mon >= 1 && a[4] > 0) {
            epoch = days_from_civil(a[4], mon, a[2]) * 86400 + t[1] * 3600 + t[2] * 60 + t[3]
            diff = epoch - now
            days = diff >= 0 ? int(diff / 86400) : -int((-diff + 86399) / 86400)
            expires = sprintf("%04d-%02d-%02d %02d:%02d:%02d", a[4], mon, a[2], t[1], t[2], t[3])
            key = days
        } else {
            days = "Unknown"; expires = e; key = 999999
        }
        printf "%s\t%s|%s|%s|%s|%s|%s\n", key, s, i, expires, days, fp, path
        rows++
    }
    END { if (!rows) printf "0\tNone|||||\n" }' | sort -t "$tab" -k1,1n | cut -f2-
exit 0