Last active
November 24, 2020 13:24
-
-
Save brunerd/1e8402b70ab02115852badfd1536fd41 to your computer and use it in GitHub Desktop.
Check the signing certificates on pkg packages and apps
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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