Skip to content

Instantly share code, notes, and snippets.

@brunerd
Last active November 24, 2020 13:24
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save brunerd/1e8402b70ab02115852badfd1536fd41 to your computer and use it in GitHub Desktop.
Save brunerd/1e8402b70ab02115852badfd1536fd41 to your computer and use it in GitHub Desktop.
Check the signing certificates on pkg packages and apps
#!/bin/bash
#certChecker - gets the certificate expiration(s) from a pkg or an app and outputs as CSV
: <<-EOL
MIT License
Copyright (c) 2020 Joel Bruner (brunerd.com)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
EOL
#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 %Z")
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment