#!/bin/bash #Joel "brunerd" Bruner - gets the certificate expiration(s) from a pkg or an app and outputs as CSV #hold down command at launch or touch /tmp/debug to enable xtrace command expansion commandKeyDown=$(/usr/bin/python -c 'import Cocoa; print Cocoa.NSEvent.modifierFlags() & Cocoa.NSCommandKeyMask > 1') [ "$commandKeyDown" == "True" -o -f /tmp/debug ] && set -x && xtraceFlag=1 ############# # VARIABLES # ############# #the name of this script myName=$(basename "${0}") myFolder="$(pwd)" #a header for our CSV data CSVHeader="Name,Expiration,Evaluation" #for matching app evaluation unsigned_codesign="code object is not signed at all" #for matching pkg evaluation expired_pkgutil="Status: signed by a certificate that has since expired" valid_pkgutil="Status: signed by a certificate trusted by Mac OS X" untrusted_pkgutil="Status: signed by untrusted certificate" signed_pkgutil="Status: signed Apple Software" unsigned_pkgutil="Status: no signature" #for cert reconstruction later head="-----BEGIN CERTIFICATE-----" tail="-----END CERTIFICATE-----" ############# # FUNCTIONS # ############# function printUsage { echo -e "Usage: ${myName} [-a] [target] [...]\n" echo -e "Switches:\n-a prints all cert expiration dates, otherwise only the cert expiring soonest is shown.\n" echo -e "Arguments:\ntarget - can be one or more files or folders. All *pkg and *app items are examined within a folder.\n" } function checkPackageExpiration { local item="${1}" local itemBasename="$(basename ${item})" local output expirationX509 expirationEpoch expirationEpochPrevious #see if the item is even signed local checkSigOutput=$(pkgutil --check-signature "$item") #get the signing status local sigStatusString=$(grep 'Status: ' <<< "${checkSigOutput}" | sed 's/^[ \t]*//g') #match it up and give it a friendly output name case ${sigStatusString} in "${expired_pkgutil}") local sigStatusString_friendly="EXPIRED" ;; "${valid_pkgutil}") local sigStatusString_friendly="VALID" ;; "${untrusted_pkgutil}") local sigStatusString_friendly="UNTRUSTED" ;; "${signed_pkgutil}") local sigStatusString_friendly="SIGNED" ;; "${unsigned_pkgutil}") local sigStatusString_friendly="UNSIGNED" ;; esac #if something goes wrong if [ "${sigStatusString_friendly}" = "UNSIGNED" ]; then echo "${itemBasename},,UNSIGNED" continue fi #create tab separated certs from the package xml (replace node with tab) and strip off all the xpath stuff and blank lines local certificatesRAW=$(xpath //X509Certificate/'text()' 2>&1 <<< "$(xar --dump-toc=/dev/stdout -f "$item")" | sed -e 's/-- NODE --/'$'\t''/g' -e '/Found/d' -e '/^$/d' -e '/No nodes found/d') #make an array of the certs IFS=$'\t' GLOBIGNORE='*' command eval 'certArray=( ${certificatesRAW} )' #loop through cert array and output expiration stats for ((i=0; i < ${#certArray[@]}; i++)); do #put header and footer on cert data local fullCert="${head}"$'\n'"$(sed '/^$/d' <<< "${certArray[i]}")"$'\n'"${tail}" #get expiration, get just the date, eliminate double spaces single digit days expirationX509=$(openssl x509 -enddate -noout -in /dev/stdin <<< "${fullCert}" 2>/dev/null | awk -F'=' '{print $2}' | tr -s ' ') #if something goes wrong if [ -z "${expirationX509}" ]; then echo "${itemBasename},,ERROR" continue fi expirationEpoch=$(/bin/date -j -f "%b %d %T %Y %Z" "${expirationX509}" "+%s") expirationSortable=$(/bin/date -j -f "%b %d %T %Y %Z" "${expirationX509}" "+%Y-%m-%d %H:%M:%S %Z") #build output string(s) for all certs if [ -n "${allCerts_flag}" ]; then #build the multi-line output [ -z "${output}" ] && output+="${itemBasename},${expirationSortable},${sigStatusString_friendly}" || output+=$'\n'"${itemBasename},${expirationSortable},${sigStatusString_friendly}" #only have the lowest number else #if we haven't set that if [ -z "${expirationEpochPrevious}" ]; then #set the output string output="$(basename "${item}"),${expirationSortable},${sigStatusString_friendly}" #if we are lower than the last one add that to the output elif [ "${expirationEpoch}" -lt "${expirationEpochPrevious}" ]; then #set the output string output="$(basename "${item}"),${expirationSortable},${sigStatusString_friendly}" fi fi #set this for the next loop expirationEpochPrevious="${expirationEpoch}" done if [ -n "${allCerts_flag}" ]; then #output sorted uniq string - when we were doing multiline output output=$(sort -r <<< "${output}" | uniq) fi #only if we have output echo it [ -n "${output}" ] && echo "${output}" } function checkAppExpiration { local item="${1}" #if we have a relative path make it full so the cert extrations routines work [ "${item:0:1}" = "." ] && local item="$(pwd)/${item}" local itemBasename="$(basename ${item})" local file output expirationX509 expirationEpoch expirationEpochPrevious checkSigOutput #check the signing status of the app, capture output (why do they make the output go to stderr?) #note: if local precedes this variable the exitCode will always be 0 checkSigOutput=$(codesign -d -vvv "${item}" 2>&1) local exitCode=$? #non zero means it could not find a cert, report and continue if [ "${exitCode}" -gt 0 ]; then #get the signing status - expecting single line output local sigStatusString=$(awk -F": " '{print $2}' <<< "${checkSigOutput}") #match the output and make it friendly case ${sigStatusString} in "${unsigned_codesign}") local sigStatusString_friendly="UNSIGNED" ;; *) local sigStatusString_friendly="ERROR" ;; esac #output and go to the next echo "${itemBasename},,${sigStatusString_friendly}" continue fi #make a place for the exported certs local randomFolder="/tmp/certfiles-$RANDOM" mkdir "${randomFolder}" #the tool does not let us specify an output folder, we just have to go there cd "${randomFolder}" #export the certs quietly to the current directory (no other way) codesign -dvvvv --extract-certificates "${item}" 2>/dev/null #go back where we were (or else there's problems) cd "${myFolder}" #get all the codesign files local fileList=$(find "${randomFolder}" -name "codesign*") IFS=$'\n' #not expecting spaces, just habit #loop through cert array and output expiration stats for file in ${fileList}; do #get expiration, get just the date, eliminate double spaces single digit days expirationX509=$(openssl x509 -enddate -noout -inform DER -in "${file}" | awk -F'=' '{print $2}' | tr -s ' ') #if something goes wrong if [ -z "${expirationX509}" ]; then echo "${itemBasename},,ERROR" continue fi #get the date - 3 different ways expirationEpoch=$(/bin/date -j -f "%b %d %T %Y %Z" "${expirationX509}" "+%s") expirationSortable=$(/bin/date -j -f "%b %d %T %Y %Z" "${expirationX509}" "+%Y-%m-%d %H:%M:%S") epochTimeNow=$(date +"%s") #simple comparison no other validation done if [ "${expirationEpoch}" -lt "${epochTimeNow}" ]; then sigStatusString_friendly="EXPIRED" else sigStatusString_friendly="VALID" fi #if -a flag, build output string(s) for all certs if [ -n "${allCerts_flag}" ]; then #build the multi-line output [ -z "${output}" ] && output+="${itemBasename},${expirationSortable},${sigStatusString_friendly}" || output+=$'\n'"${itemBasename},${expirationSortable},${sigStatusString_friendly}" #or output the nearest expiration else #if we haven't set that if [ -z "${expirationEpochPrevious}" ]; then #set the output string output="${itemBasename},${expirationSortable},${sigStatusString_friendly}" #if we are lower than the last one add that to the output elif [ "${expirationEpoch}" -lt "${expirationEpochPrevious}" ]; then #set the output string output="${itemBasename},${expirationSortable},${sigStatusString_friendly}" fi fi #set this for the next loop expirationEpochPrevious="${expirationEpoch}" done #cleanup rm -rf "${randomFolder}" if [ -n "${allCerts_flag}" ]; then #output sorted uniq string - when we were doing multiline output output=$(sort -r <<< "${output}" | uniq) fi #only if we have output echo it [ -n "${output}" ] && echo "${output}" } function processTarget { #strip any trailing slash off local target="${1%/}" local item; IFS=$'\n' #if directory get all the .(m)pkg or .app files/folders inside if [ -d "${target}" ]; then fileList=$(find "${target}" -name '*pkg' -or -name '*app' | sort) else fileList="${target}" fi #got through each file for item in ${fileList}; do #trim every thing before the last dor itemExtension=${item##*.} if [ "${itemExtension}" = "pkg" ]; then checkPackageExpiration "${item}" elif [ "${itemExtension}" = "app" ]; then checkAppExpiration "${item}" fi done } ######## # MAIN # ######## #options processing while getopts ":ah" option; do case "${option}" in #print all certs 'a') #flag vars simply need to be set or not allCerts_flag='Y' ;; 'h') printUsage exit ;; esac done #shift past options so $1 is still $1 after the options are processed shift $((OPTIND-1)) #if no arguments after options processing, offer help #btw you cannot yest ${@} becuase multiple items appear as multiple arguments to [ (test) so we eval an echo of it if [ -z "$(eval echo ${@})" ]; then #let you know there an option or two to be had echo "Please provide target(s) to examine..." echo "Run \"${myName} -h\" for help" exit fi #only respect newlines in the for loop IFS=$'\n' echo "${CSVHeader}" #keep going until you can't do no more for target in ${@}; do processTarget "${target}" done