Navigation Menu

Skip to content

Instantly share code, notes, and snippets.

@jerrykrinock
Last active August 25, 2018 19:04
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 jerrykrinock/9d2359234797da97a94d to your computer and use it in GitHub Desktop.
Save jerrykrinock/9d2359234797da97a94d to your computer and use it in GitHub Desktop.
Signs (codesign) with Developer ID, and zips an OS X app, recursively, from the inside out. When I wrote this script, Apple's `codesign` tool did not do the recursion and therefore did not work. Maybe today it does. Try Apple's tool before using this. This is handy for sending AppleScripts (in the form of OS X apps) to individual users who need …
#!/usr/bin/perl
use strict ;
use IPC::Run ;
use File::Basename ;
use File::Util ;
# Sometimes this is necessary for modules in this directory to be found at compile time when running on my Mac:
use lib '/Users/jk/Documents/Programming/Scripts' ;
# Edit the following section to reference your identity…
my $codeSigningIdentity = "Developer ID Application: Jerry Krinock" ;
my $developerTeamId = "4MAMECY9VS" ;
my $path = $ARGV[0] ;
my $fileUtil = File::Util->new() ;
# The following line recursively code signs the entire product
codesignDeveloperID($path, $codeSigningIdentity) ;
my $command ;
my @sysargs ;
my @args ;
my $stdin ;
my $stdout ;
my $stderr ;
my $exitOk ;
# Remove any zipped product from prior run
$command = "/bin/rm" ;
@args = ("-f", "\"$path.zip\"") ;
$exitOk = IPC::Run::run [ $command, @args ], \$stdin, \$stdout, \$stderr ;
if (!$exitOk) {
die("Failed removing prior zip with command:\n$command @args : $stdout $stderr") ;
}
# Zip it
my $parentPath = dirname($path) ;
my $filename = basename($path) ;
my $zipName = "$filename.zip" ;
$command = "cd $parentPath ; /usr/bin/zip -r -y \"$zipName\" \"$filename\"" ;
print "Executing command:\n$command\n" ;
my $zipResult = `$command` ;
print "\nzipResult = $zipResult\n" ;
print "Your signed and zipped product is here:\n $parentPath/$zipName\n" ;
print "\nWould you like me to print a email snippet you could send to a user telling them how to unzip and run the zipped attachment?\n" ;
print " Type 's' to get the snippet.\n" ;
print " Type 'return' or any key other key to exit.\n" ;
my $userInputChar = substr <STDIN>, 0, 1 ;
if ($userInputChar eq 's') {
my $productName = basename($path) ;
my $snippet = <<SNIPPET ;
Please do this…
• Drag the attachment, $zipName, which appears below, to your Desktop.
• Double-click the dragged attachment on your Desktop. A program named $productName will appear.
• Double-click $productName to run it.
SNIPPET
print $snippet ;
}
### SUBROUTINES ###
sub codesignDeveloperID {
my $productPath = shift ;
my $codeSigningIdentity = shift ;
# We shall use IPC::Run::run() for noisy programs that would otherwise noise up the system's stdout or stderr, or whose stdout or stderr contains data which we need to parse.
# Things we'll need for IPC::Run::run().
my $command ;
my @args ;
my $stdin ;
my $stdout ;
my $stderr ;
my $exitOk ;
# Starting with the Xcode 8 Developer Tools, codesign will fail if any component of a target package has any extended attributes, saying "resource fork, Finder information, or similar detritus not allowed". So we recursively remove those.
@sysargs = ("/usr/bin/xattr", "-r", "-c", "\"$productPath\"") ;
systemDoOrDie(@sysargs) ;
# On 20160922, while working on this script suddenly codesign failed to work because a FirefoxProfileFinder.cstemp file was found in "Sheep Systems Trouble Zipper.app/Contents/MacOS. This file was not there yesterday, and the only thing I did to this package recently was to codesign it using this script. The codesign tool said "/Users/jk/Documents/AppleScripts/Sheep Systems Trouble Zipper.app/Contents/MacOS/FirefoxProfileFinder.cstemp: invalid or unsupported format for signature". I concluded that .cstemp is a "code signing temporary" file which was left there from an aborted prior codesign attempt. To avoid that from happening again in the future, we now search for and delete any of those with the following. That fixed it.
@sysargs = ("/usr/bin/find", "\"$productPath\"", "-name", "*.cstemp", "-type", "f", "-delete") ;
systemDoOrDie(@sysargs) ;
# Apple's codesign utility does not seem to support file globbed paths, so we use File::Util to get arrays of the contents of the known directories that may have executables that need to be signed.
my @pathsToSign ;
# The scanBundle subroutines dig in recursively
scanBundleForCodesign($productPath, \@pathsToSign, " ") ;
# Starting in Mac OS X 10.9, the codesign tool requires that code components of any bundle to be signed be signed first. In other words, bundles must be signed in order, from the inside out. Because scanBundleForCodesign processes from the outside in, we now reverse its results. (Note that this could not be done inside scanBundle because that function calls itself recursively.)
@pathsToSign = reverse(@pathsToSign) ;
my $nPaths = @pathsToSign ;
print "Found $nPaths signable files. Files must and will be be signed starting from the innermost and working up to the root. Here are the paths to be signed, in order of how they will be signed:\n" ;
for (my $i=0; $i<$nPaths; $i++) {
print "$pathsToSign[$i]\n" ;
}
my $nPathsToSign = @pathsToSign ;
my $nTroubles = 0 ;
my @codeSignIdentifiers ;
for (my $i=0; $i<@pathsToSign; $i++) {
my $j = $i+1 ;
my $pathToSign = $pathsToSign[$i] ;
my $name = lastPathComponent(removeIfSuffix("/Versions/A", $pathToSign)) ;
# I tried checking for file existence here but got really strange results. So at this time there is no test. Script will fail if Code Signing fails for any item.
if (1) {
# Per Quinn "The Eskimo", if the executable is that of an app bundle, the code signing identifier should be that bundle's bundle identifier. If the executable is a command-line tool with an embedded Info.plist, the code signing identifier should be the bundle identifier in that embedded Info.plist. Reference: https://forums.developer.apple.com/thread/107546. Our extractBundleIdentifier() will give the correct answer in either case.
print "\Signing component: $j/$nPathsToSign: $name\n";
@sysargs = ("/usr/bin/codesign", "--force", "--verbose", "--sign", "\"$codeSigningIdentity\"", "\"$pathToSign\"") ;
# Starting in August 2018, I do not pass the code signing identifier to as an argument to codesign. I let codesign determine it. Per Quinn "The Eskimo", if the executable is that of an app bundle, the code signing identifier should be that bundle's bundle identifier. If the executable is a command-line tool with an embedded Info.plist, the code signing identifier should be the bundle identifier in that embedded Info.plist. Reference: https://forums.developer.apple.com/thread/107546.
print "The Command -->";
foreach my $arg (@sysargs) {
print " $arg";
}
print "<-- End of Command\n";
systemDoOrDie(@sysargs) ;
}
else {
print " TROUBLE! No file found for $name.\n" ;
print " Complete path is:\n \"$pathToSign\"\n" ;
$nTroubles++ ;
}
}
if ($nTroubles > 0) {
print "\nThere was TROUBLE found with $nTroubles items.\n" ;
print " Type 'a' to abort this script and exit.\n" ;
print " Type 'return' or any key other key to continue DESPITE TROUBLE.\n" ;
$userInputChar = getUserInputChar() ;
if ($userInputChar eq 'a') {
die "User aborted" ;
}
}
print "\nReport of Code Signing Identifier and [Team Identifier] used for each component...\n" ;
for (my $i=0; $i<@pathsToSign; $i++) {
my $report = `codesign -d -v $pathsToSign[$i] 2>&1`;
$report =~ m/\nIdentifier=([^\n]+)/;
my $identifier = $1;
$report =~ m/\nTeamIdentifier=([^\n]+)/;
my $teamID = $1;
print " $identifier [$teamID] for $pathsToSign[$i]\n" ;
# Test the identifier, because codesign did not, and when codesign --verify runs, in the next section, an invalid identifier (which can happen if a component has an invalid bundle identifier) will print an inscrutable error.
# Valid characters in a bundle identifier are supposedly the same as the valid characters in DNS, see RFC952. The following line allows dashes, periods and ASCII alpahbet characters. (I am not sure about the underscore. It does not allow underscores.)
if ($identifier =~ m/[^-\.a-zA-Z]/) {
print "\n***WARNING*** Probably invalid code signing identifier $identifier for component $pathsToSign[$i]\n";
print "*** This may cause a \"nested code is modified or invalid\" error.\n\n";
}
}
print("\n");
# Verify code signatures
`/usr/bin/codesign --verify "$productPath"` ;
if ($?) {
die "codesign --verify result: We FAILED." ;
}
else {
print "*** codesign --verify result: We are GOOD. ***\n" ;
}
print("\n");
# Verify for Gatekeeper
my $pathToVerify = $pathsToSign[-1] ; # -1 = last element in list
print "Will check sisgnature and verify for Gatekeeper:\n $pathToVerify\n" ;
# Verify that the Code Signature is version 2 or greater. This is required for users with OS X 10.9.5 or later.
$command = "/usr/bin/codesign" ;
my @args = ("--display", "--verbose", $pathToVerify) ;
$exitOk = IPC::Run::run [ $command, @args ], \$stdin, \$stdout, \$stderr ;
# IPC::Run:run() returns "TRUE when all subcommands exit with a 0 result code." Thus, success is indicated by $exitOk = 1.
if (!$exitOk) {
die("Failed displaying code signature info with command:\n$command @args") ;
}
print "\nCodesign Info Result: exitOk=$exitOk\n stdout:\n$stdout\n stderr:\n$stderr" ;
my @infoLines = split("\n", $stderr) ;
my $hasGoodResourcesSignature = 0 ;
my $resourceVersion = 0 ;
my $relevantLine = "<NO RELEVANT DATA>" ;
for my $infoLine (@infoLines) {
# codesign --display prints its result to stderr, not stdout.
# We search for and then parse a line that typically looks like one of the following:
# Sealed Resources version=2 rules=12 files=400
# Sealed Resources=none
if ($infoLine =~ m/Sealed Resources/) {
$infoLine =~ m/version=([0-9\.]+)/ ;
my $resourceVersion = $1 ;
if ($resourceVersion >= 2) {
$hasGoodResourcesSignature = 1 ;
}
if ($stderr =~ m/none/) {
$hasGoodResourcesSignature = 1 ;
}
$relevantLine = $infoLine ;
printf("\nSealed Resources Assessment: $relevantLine\n") ;
last ;
}
}
if (!$hasGoodResourcesSignature) {
die("Product's code signature does not have required resources version. Found this: $relevantLine\n") ;
}
$command = "/usr/sbin/spctl" ;
$stdout = "<NO-STDOUT>" ;
$stderr = "<NO-STDERR>" ;
# Note that "spctl exits zero on success, or one if an operation has failed. Exit code two indicates unrecognized or unsuitable arguments". If an assessment operation results in denial but no other problem has occurred, the exit code is three." But IPC::Run:run() returns "TRUE when all subcommands exit with a 0 result code." Thus, success is indicated by $exitOk = 1.
# Before assessing the product, first make sure that Gatekeeper is enabled
@args = ("--status") ;
$exitOk = IPC::Run::run [ $command, @args ], \$stdin, \$stdout, \$stderr ;
# Note that stdout and stderr end with line feeds
print "\nGatekeeper Status Result: exitOk=$exitOk\n stdout: $stdout stderr: $stderr" ;
my $assessmentsEnabled = (($stdout =~/assessments enabled/) && $exitOk) ;
if (!$assessmentsEnabled) {
die ("Gatekeeper Assessments are not enabled, according to command:\n $command @args\nPlease run this command in Terminal:\n sudo /usr/sbin/spctl --master-enable\nto fix this problem") ;
}
# Assess the product
my @args = ("-a", "-vv", "-t", "execute", $pathToVerify) ;
$exitOk = IPC::Run::run [ $command, @args ], \$stdin, \$stdout, \$stderr ;
# Note that ends with line feed but stdout does not
print "\nGatekeeper Assessment Result: exitOk=$exitOk\n stdout:\n$stdout\n stderr:\n$stderr" ;
# spctl prints its result to stderr, not stdout.
my $assessmentOk = (($stderr =~ m/source=Developer ID/) && ($stderr =~ m/: accepted/) && $exitOk) ;
if (!$assessmentOk) {
die("Failed Gatekeeper test for Developer ID with command:\n$command @args") ;
}
}
sub scanBundleForCodesign {
my $rootPath = shift ;
my $pathsToSignRef = shift ;
my $indent = shift ;
if ($fileUtil->existent($rootPath)) {
print ($indent . "Searching for signable items in:\n$indent $rootPath\n") ;
my @rawNames ;
# First, the bundle itself. We mimic the default behavior of the codesign tool by assigning its bundle identifier to the codesign identifier.
push @$pathsToSignRef, $rootPath ;
my $i ;
# Next, any executables in Contents/MacOS, including the "main" executable (CFBundleExecutable). Signing the main seems to be harmless; it will just replace the signature which was applied when the whole bundle was signed. However, it is needed for other bundles such as XPC Services which will not be re-signed.
my $dirPath = "$rootPath/Contents/MacOS" ;
if ($fileUtil->existent($dirPath)) {
print ($indent . " Contents/MacOS/...\n") ;
my $mainExecutableName = extractInfoPlistKey($rootPath, "CFBundleExecutable", 1) ;
@rawNames = $fileUtil->list_dir($dirPath, qw/--no-fsdots --files-only/) ;
for ($i=0; $i<@rawNames; $i++) {
push @$pathsToSignRef, $dirPath . "/" . $rawNames[$i] ;
}
}
# Next, any executables, or helper apps in Contents/Helpers.
$dirPath = "$rootPath/Contents/Helpers" ;
if ($fileUtil->existent($dirPath)) {
print ($indent . " Contents/Helpers/...\n") ;
# Executables
@rawNames = $fileUtil->list_dir($dirPath, qw/--no-fsdots --files-only/) ;
for ($i=0; $i<@rawNames; $i++) {
push @$pathsToSignRef, $dirPath . "/" . $rawNames[$i] ;
}
# Helper apps
@rawNames = $fileUtil->list_dir($dirPath, qw/--no-fsdots --dirs-only/) ;
my $dotApp = ".app" ;
my $dotAppLength = length($dotApp) ;
for ($i=0; $i<@rawNames; $i++) {
# This is the recursion
scanBundleForCodesign ("$dirPath/$rawNames[$i]", $pathsToSignRef, "$indent ") ;
}
}
# We skip Contents/Resources since there should not be any code in there. Only code gets signed.
# Next, the executables in any frameworks in Contents/Frameworks
$dirPath = "$rootPath/Contents/Frameworks" ;
if ($fileUtil->existent($dirPath)) {
print ($indent . " Contents/Frameworks/...\n") ;
scanFrameworksForCodesign($dirPath, $pathsToSignRef) ;
}
# Next, any plugins in Contents/Plugins
$dirPath = "$rootPath/Contents/Plugins" ;
if ($fileUtil->existent($dirPath)) {
print ($indent . " Contents/Plugins/...\n") ;
my @pluginNames = $fileUtil->list_dir($dirPath, qw/--no-fsdots --dirs-only/) ;
my $dotPlugin = ".plugin" ;
my $dotPluginLength = length($dotPlugin) ;
for ($i=0; $i<@pluginNames; $i++) {
if (index($pluginNames[$i], $dotPlugin, length($pluginNames[$i]) - $dotPluginLength) > 0) {
my $pluginPath = $dirPath . "/" . $pluginNames[$i] ;
push @$pathsToSignRef, $pluginPath ;
}
}
}
# Next, any XPC services in Contents/XPCServices
my $dirPath = "$rootPath/Contents/XPCServices";
scanXPCServicesForCodesign($dirPath, $pathsToSignRef);
}
}
sub scanFrameworksForCodesign {
my $dirPath = shift ;
my $pathsToSignRef = shift ;
if ($fileUtil->existent($dirPath)) {
my @frameworkNames = $fileUtil->list_dir($dirPath, qw/--no-fsdots --dirs-only/) ;
for (my $i=0; $i<@frameworkNames; $i++) {
my $frameworkDirPath = $dirPath . "/" . $frameworkNames[$i] ;
# The following line picks out the name of the framework executable(s), by getting all *regular* files (that is, excluding directories) which are immediate children of Whatever.framework. Usually, there will be only one, a symlink to the framework executable which is buried in /Versions/A
my @rawNames = $fileUtil->list_dir($frameworkDirPath, qw/--no-fsdots --files-only/) ;
for (my $j=0; $j<@rawNames; $j++) {
# The following is per Apple TN 2206 which says "To avoid problems when signing frameworks make sure that you sign a specific version as opposed to the whole framework ... This is the right way:
# codesign -s my-signing-identity ../FooBarBaz.framework/Versions/A
push @$pathsToSignRef, $frameworkDirPath . "/Versions/A" ;
}
# Recurse into any XPC Services
my $xpcServicesDir = "$frameworkDirPath/XPCServices";
scanXPCServicesForCodesign($xpcServicesDir, $pathsToSignRef);
# Recurse into any subframeworks
my $subframeworksDir = "$frameworkDirPath/Versions/A/Frameworks" ;
scanFrameworksForCodesign($subframeworksDir, $pathsToSignRef) ;
}
}
}
sub scanXPCServicesForCodesign {
my $dirPath = shift ;
my $pathsToSignRef = shift ;
if ($fileUtil->existent($dirPath)) {
my @xpcNames = $fileUtil->list_dir($dirPath, qw/--no-fsdots --dirs-only/) ;
for (my $j=0; $j<@xpcNames; $j++) {
my $xpcServicesBundle = $dirPath . "/" . $xpcNames[$j];
scanBundleForCodesign($xpcServicesBundle, $pathsToSignRef);
}
}
}
sub extractBundleVersion {
my $path = shift ;
my $dieIfFail = shift ;
return (extractInfoPlistKey($path, "CFBundleVersion", $dieIfFail)) ;
}
sub extractInfoPlistKey {
my $path = shift ;
my $key = shift ;
my $dieIfFail = shift ;
my $infoPlistPath = "$path/Contents/Info.plist" ;
# We shall use IPC::Run::run() for noisy programs that would otherwise noise up the system's stdout or stderr, or whose stdout or stderr contains data which we need to parse.
# Things we'll need for IPC::Run::run().
my $command ;
my @args ;
my $stdin ; # Leave as undef
my $stdout = "<??>" ;
my $stderr = "<??>" ;
my $exitOk ;
my $msg ; # Leave as undef
$command = '/usr/libexec/PlistBuddy' ;
# IPC::Run() quotes arguments, so in the following, we do not add \".
@args = ("-c", "Print:$key", "$infoPlistPath") ;
my $value ;
# Note that PlistBuddy exits zero on success. But IPC::Run:run() returns "TRUE when all subcommands exit with a 0 result code.
$exitOk = IPC::Run::run [ $command, @args ], \$stdin, \$stdout, \$stderr ;
if ($exitOk) {
if (!defined($stdout)) {
$msg = "Did not get $key from $infoPlistPath\nGot stdout: $stdout\nGot stderr: $stderr" ;
}
else {
if (length($stdout) < 1) {
$msg = "Got empty $key \"$stdout\" from $infoPlistPath\nGot stderr: $stderr" ;
}
else {
# Success
$value = $stdout ;
chomp($value) ;
}
}
}
else {
$msg = "Error while reading $key from $infoPlistPath" ;
}
if (!$value && $dieIfFail) {
if (!defined($msg)) {
$msg = "Failed to extract $key from $infoPlistPath\n" ;
}
die($msg) ;
}
return $value ;
}
sub filenameOfPath {
my $arg = shift ;
my @pathPieces = File::Spec->splitpath($arg) ;
my $filename = @pathPieces[2] ;
return $filename ;
}
sub programName {
return filenameOfPath($0) ;
}
sub currentWorkingDirectory {
return $ENV{'PWD'} ;
# I also tried to do this "the right way, using high-level API".
# However, the answer that I get from the following is ".". Duh.
# return File::Spec->curdir() ;
}
# An enhanced "die" which also prints the current directory, handy for debugging when a script is invoking system processes!
sub stop {
printf "$_. Current directory is:\n %s\n", currentWorkingDirectory() ;
die (shift) ;
}
sub commandStringFromArray {
# Recover the array argument, which perl has flattened
my @sysargs ;
my $s ;
while (my $someArg = shift) {
$s .= $someArg ;
$s .= " " ;
}
return $s ;
}
# This function allows glob expansion. Spaces in arguments must be quoted or escaped.
sub systemDoOrDie {
# First argument to this function should be the command
# Subsequent arguments should be the space-separated "arguments" to the command
# Each space makes a new argument, thus a command option such as
# -o /some/path
# should be passed as two arguments, "-o" and "/some/path"
# Of course, since perl flattens arrays passed to functions, all
# or part of the arguments may be concatenated into array(s)
my $programName = programName() ;
# Recover the array argument, which perl has flattened
my @sysargs ;
while (my $arg = shift) {
push @sysargs, $arg ;
}
#push @sysargs, ">" ;
#push @sysargs, "/dev/null" ;
my $commandName = @sysargs[0] ;
my $commandString = commandStringFromArray(@sysargs) ;
# There are two ways to do this, and both work if there are no metacharacters such as the asterisk (*) in the command. The first way is:
# system(@sysargs) ; # Literally interprets and thus spoils operation of metacharacters
# So, instead we use the second way:
system($commandString) ; # Always works even if metacharacters.
# $? has the same value as the return value of system()
# I use the former since it is more convenient.
if ($? != 0) {
stop ("Failed with status=$? executing:\n $commandString\nDied") ;
}
if ($? == -1) {
print "$programName: Failed to execute:\n $commandString\nError message:\n $!\n";
}
elsif ($? & 127) {
printf "$programName: Failed while executing command:\n $commandString\nFailed with signal %d, %s coredump.\n",
($? & 127), ($? & 128) ? 'with' : 'without';
}
return $? ;
}
# Returns the last path component of a path separated by "/" characters, including the trailing slash if the last component ends in a trailing slash.
sub lastPathComponent {
my $path = shift ;
my @splits = split (/\//, $path) ;
my @rev = reverse(@splits) ;
my $lpc = $rev[0] ;
if (substr(reverse($path), 0, 1) eq "/") {
$lpc .= "/" ;
}
return $lpc ;
}
sub removeIfSuffix {
my $suffix = shift ;
my $path = shift ;
my $suffixLen = length($suffix) ;
my $wholeLen = length($path) ;
my $end = substr($path, $wholeLen - $suffixLen, $suffixLen) ;
my $answer ;
if ($end eq $suffix) {
$answer = substr($path, 0, $wholeLen - $suffixLen) ;
}
else {
$answer = $path ;
}
return $answer ;
}
sub getUserInputChar {
return substr <STDIN>, 0, 1 ;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment