#!/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