#! /usr/bin/python # # File: SMJobBlessUtil.py # # Contains: Tool for checking and correcting apps that use SMJobBless. # # Written by: DTS # # Copyright: Copyright (c) 2012 Apple Inc. All Rights Reserved. # # Disclaimer: IMPORTANT: This Apple software is supplied to you by Apple Inc. # ("Apple") in consideration of your agreement to the following # terms, and your use, installation, modification or # redistribution of this Apple software constitutes acceptance of # these terms. If you do not agree with these terms, please do # not use, install, modify or redistribute this Apple software. # # In consideration of your agreement to abide by the following # terms, and subject to these terms, Apple grants you a personal, # non-exclusive license, under Apple's copyrights in this # original Apple software (the "Apple Software"), to use, # reproduce, modify and redistribute the Apple Software, with or # without modifications, in source and/or binary forms; provided # that if you redistribute the Apple Software in its entirety and # without modifications, you must retain this notice and the # following text and disclaimers in all such redistributions of # the Apple Software. Neither the name, trademarks, service marks # or logos of Apple Inc. may be used to endorse or promote # products derived from the Apple Software without specific prior # written permission from Apple. Except as expressly stated in # this notice, no other rights or licenses, express or implied, # are granted by Apple herein, including but not limited to any # patent rights that may be infringed by your derivative works or # by other works in which the Apple Software may be incorporated. # # The Apple Software is provided by Apple on an "AS IS" basis. # APPLE MAKES NO WARRANTIES, EXPRESS OR IMPLIED, INCLUDING # WITHOUT LIMITATION THE IMPLIED WARRANTIES OF NON-INFRINGEMENT, # MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, REGARDING # THE APPLE SOFTWARE OR ITS USE AND OPERATION ALONE OR IN # COMBINATION WITH YOUR PRODUCTS. # # IN NO EVENT SHALL APPLE BE LIABLE FOR ANY SPECIAL, INDIRECT, # INCIDENTAL OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED # TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) ARISING IN ANY WAY # OUT OF THE USE, REPRODUCTION, MODIFICATION AND/OR DISTRIBUTION # OF THE APPLE SOFTWARE, HOWEVER CAUSED AND WHETHER UNDER THEORY # OF CONTRACT, TORT (INCLUDING NEGLIGENCE), STRICT LIABILITY OR # OTHERWISE, EVEN IF APPLE HAS BEEN ADVISED OF THE POSSIBILITY OF # SUCH DAMAGE. # import sys import os import getopt import subprocess import plistlib import operator class UsageException (Exception): """ Raised when the progam detects a usage issue; the top-level code catches this and prints a usage message. """ pass class CheckException (Exception): """ Raised when the "check" subcommand detects a problem; the top-level code catches this and prints a nice error message. """ def __init__(self, message, path=None): self.message = message self.path = path def checkCodeSignature(programPath, programType): """Checks the code signature of the referenced program.""" # Use the codesign tool to check the signature. The second "-v" is required to enable # verbose mode, which causes codesign to do more checking. By default it does the minimum # amount of checking ("Is the program properly signed?"). If you enabled verbose mode it # does other sanity checks, which we definitely want. The specific thing I'd like to # detect is "Does the code satisfy its own designated requirement?" and I need to enable # verbose mode to get that. args = [ # "false", "codesign", "-v", "-v", programPath ] try: subprocess.check_call(args, stderr=open("/dev/null")) except subprocess.CalledProcessError, e: raise CheckException("%s code signature invalid" % programType, programPath) def readDesignatedRequirement(programPath, programType): """Returns the designated requirement of the program as a string.""" args = [ # "false", "codesign", "-d", "-r", "-", programPath ] try: req = subprocess.check_output(args, stderr=open("/dev/null")) except subprocess.CalledProcessError, e: raise CheckException("%s designated requirement unreadable" % programType, programPath) reqLines = req.splitlines() if len(reqLines) != 1 or not req.startswith("designated => "): raise CheckException("%s designated requirement malformed" % programType, programPath) return reqLines[0][len("designated => "):] def readInfoPlistFromPath(infoPath): """Reads an "Info.plist" file from the specified path.""" try: info = plistlib.readPlist(infoPath) except: raise CheckException("'Info.plist' not readable", infoPath) if not isinstance(info, dict): raise CheckException("'Info.plist' root must be a dictionary", infoPath) return info def readPlistFromToolSection(toolPath, segmentName, sectionName): """Reads a dictionary property list from the specified section within the specified executable.""" # Run otool -s to get a hex dump of the section. args = [ # "false", "otool", "-s", segmentName, sectionName, toolPath ] try: plistDump = subprocess.check_output(args) except subprocess.CalledProcessError, e: raise CheckException("tool %s / %s section unreadable" % (segmentName, sectionName), toolPath) # Convert that hex dump to an property list. plistLines = plistDump.splitlines() if len(plistLines) < 3 or plistLines[1] != ("Contents of (%s,%s) section" % (segmentName, sectionName)): raise CheckException("tool %s / %s section dump malformed (1)" % (segmentName, sectionName), toolPath) del plistLines[0:2] try: bytes = [] for line in plistLines: # line looks like this: # # '0000000100000b80\t3c 3f 78 6d 6c 20 76 65 72 73 69 6f 6e 3d 22 31 ' columns = line.split("\t") assert len(columns) == 2 for hexStr in columns[1].split(): bytes.append(int(hexStr, 16)) plist = plistlib.readPlistFromString(bytearray(bytes)) except: raise CheckException("tool %s / %s section dump malformed (2)" % (segmentName, sectionName), toolPath) # Check the root of the property list. if not isinstance(plist, dict): raise CheckException("tool %s / %s property list root must be a dictionary" % (segmentName, sectionName), toolPath) return plist def checkStep1(appPath): """Checks that the app and the tool are both correctly code signed.""" if not os.path.isdir(appPath): raise CheckException("app not found", appPath) # Check the app's code signature. checkCodeSignature(appPath, "app") # Check the tool directory. toolDirPath = os.path.join(appPath, "Contents", "Library", "LaunchServices") if not os.path.isdir(toolDirPath): raise CheckException("tool directory not found", toolDirPath) # Check each tool's code signature. toolPathList = [] for toolName in os.listdir(toolDirPath): if toolName != ".DS_Store": toolPath = os.path.join(toolDirPath, toolName) if not os.path.isfile(toolPath): raise CheckException("tool directory contains a directory", toolPath) checkCodeSignature(toolPath, "tool") toolPathList.append(toolPath) # Check that we have at least one tool. if len(toolPathList) == 0: raise CheckException("no tools found", toolDirPath) return toolPathList def checkStep2(appPath, toolPathList): """Checks the SMPrivilegedExecutables entry in the app's "Info.plist".""" # Create a map from the tool name (not path) to its designated requirement. toolNameToReqMap = dict() for toolPath in toolPathList: req = readDesignatedRequirement(toolPath, "tool") toolNameToReqMap[os.path.basename(toolPath)] = req # Read the Info.plist for the app and extract the SMPrivilegedExecutables value. infoPath = os.path.join(appPath, "Contents", "Info.plist") info = readInfoPlistFromPath(infoPath) if not info.has_key("SMPrivilegedExecutables"): raise CheckException("'SMPrivilegedExecutables' not found", infoPath) infoToolDict = info["SMPrivilegedExecutables"] if not isinstance(infoToolDict, dict): raise CheckException("'SMPrivilegedExecutables' must be a dictionary", infoPath) # Check that the list of tools matches the list of SMPrivilegedExecutables entries. if sorted(infoToolDict.keys()) != sorted(toolNameToReqMap.keys()): raise CheckException("'SMPrivilegedExecutables' and tools in 'Contents/Library/LaunchServices' don't match") # Check that all the requirements match. # This is an interesting policy choice. Technically the tool just needs to match # the requirement listed in SMPrivilegedExecutables, and we can check that by # putting the requirement into tmp.req and then running # # $ codesign -v -R tmp.req /path/to/tool # # However, for a Developer ID signed tool we really want to have the SMPrivilegedExecutables # entry contain the tool's designated requirement because Xcode has built a # more complex DR that does lots of useful and important checks. So, as a matter # of policy we require that the value in SMPrivilegedExecutables match the tool's DR. for toolName in infoToolDict: if infoToolDict[toolName] != toolNameToReqMap[toolName]: raise CheckException("tool designated requirement (%s) doesn't match entry in 'SMPrivilegedExecutables' (%s)" % (toolNameToReqMap[toolName], infoToolDict[toolName])) def checkStep3(appPath, toolPathList): """Checks the "Info.plist" embedded in each helper tool.""" # First get the app's designated requirement. appReq = readDesignatedRequirement(appPath, "app") # Then check that the tool's SMAuthorizedClients value matches it. for toolPath in toolPathList: info = readPlistFromToolSection(toolPath, "__TEXT", "__info_plist") if not info.has_key("CFBundleInfoDictionaryVersion") or info["CFBundleInfoDictionaryVersion"] != "6.0": raise CheckException("'CFBundleInfoDictionaryVersion' in tool __TEXT / __info_plist section must be '6.0'", toolPath) if not info.has_key("CFBundleIdentifier") or info["CFBundleIdentifier"] != os.path.basename(toolPath): raise CheckException("'CFBundleIdentifier' in tool __TEXT / __info_plist section must match tool name", toolPath) if not info.has_key("SMAuthorizedClients"): raise CheckException("'SMAuthorizedClients' in tool __TEXT / __info_plist section not found", toolPath) infoClientList = info["SMAuthorizedClients"] if not isinstance(infoClientList, list): raise CheckException("'SMAuthorizedClients' in tool __TEXT / __info_plist section must be an array", toolPath) if len(infoClientList) != 1: raise CheckException("'SMAuthorizedClients' in tool __TEXT / __info_plist section must have one entry", toolPath) # Again, as a matter of policy we require that the SMAuthorizedClients entry must # match exactly the designated requirement of the app. if infoClientList[0] != appReq: raise CheckException("app designated requirement (%s) doesn't match entry in 'SMAuthorizedClients' (%s)" % (appReq, infoClientList[0]), toolPath) def checkStep4(appPath, toolPathList): """Checks the "launchd.plist" embedded in each helper tool.""" for toolPath in toolPathList: launchd = readPlistFromToolSection(toolPath, "__TEXT", "__launchd_plist") if not launchd.has_key("Label") or launchd["Label"] != os.path.basename(toolPath): raise CheckException("'Label' in tool __TEXT / __launchd_plist section must match tool name", toolPath) # We don't need to check that the label matches the bundle identifier because # we know it matches the tool name and step 4 checks that the tool name matches # the bundle identifier. def checkStep5(appPath): """There's nothing to do here; we effectively checked for this is steps 1 and 2.""" pass def check(appPath): """Checks the SMJobBless setup of the specified app.""" # Each of the following steps matches a bullet point in the SMJobBless header doc. toolPathList = checkStep1(appPath) checkStep2(appPath, toolPathList) checkStep3(appPath, toolPathList) checkStep4(appPath, toolPathList) checkStep5(appPath) def setreq(appPath, appInfoPlistPath, toolInfoPlistPaths): """ Reads information from the built app and uses it to set the SMJobBless setup in the specified app and tool Info.plist source files. """ if not os.path.isdir(appPath): raise CheckException("app not found", appPath) if not os.path.isfile(appInfoPlistPath): raise CheckException("app 'Info.plist' not found", appInfoPlistPath) for toolInfoPlistPath in toolInfoPlistPaths: if not os.path.isfile(toolInfoPlistPath): raise CheckException("app 'Info.plist' not found", toolInfoPlistPath) # Get the designated requirement for the app and each of the tools. appReq = readDesignatedRequirement(appPath, "app") toolDirPath = os.path.join(appPath, "Contents", "Library", "LaunchServices") if not os.path.isdir(toolDirPath): raise CheckException("tool directory not found", toolDirPath) toolNameToReqMap = {} for toolName in os.listdir(toolDirPath): req = readDesignatedRequirement(os.path.join(toolDirPath, toolName), "tool") toolNameToReqMap[toolName] = req if len(toolNameToReqMap) > len(toolInfoPlistPaths): raise CheckException("tool directory has more tools (%d) than you've supplied tool 'Info.plist' paths (%d)" % (len(toolNameToReqMap), len(toolInfoPlistPaths)), toolDirPath) if len(toolNameToReqMap) < len(toolInfoPlistPaths): raise CheckException("tool directory has fewer tools (%d) than you've supplied tool 'Info.plist' paths (%d)" % (len(toolNameToReqMap), len(toolInfoPlistPaths)), toolDirPath) # Build the new value for SMPrivilegedExecutables. appToolDict = {} toolInfoPlistPathToToolInfoMap = {} for toolInfoPlistPath in toolInfoPlistPaths: toolInfo = readInfoPlistFromPath(toolInfoPlistPath) toolInfoPlistPathToToolInfoMap[toolInfoPlistPath] = toolInfo if not toolInfo.has_key("CFBundleIdentifier"): raise CheckException("'CFBundleIdentifier' not found", toolInfoPlistPath) bundleID = toolInfo["CFBundleIdentifier"] if not isinstance(bundleID, basestring): raise CheckException("'CFBundleIdentifier' must be a string", toolInfoPlistPath) appToolDict[bundleID] = toolNameToReqMap[bundleID] # Set the SMPrivilegedExecutables value in the app "Info.plist". appInfo = readInfoPlistFromPath(appInfoPlistPath) needsUpdate = not appInfo.has_key("SMPrivilegedExecutables") if not needsUpdate: oldAppToolDict = appInfo["SMPrivilegedExecutables"] if not isinstance(oldAppToolDict, dict): raise CheckException("'SMPrivilegedExecutables' must be a dictionary", appInfoPlistPath) appToolDictSorted = sorted(appToolDict.iteritems(), key=operator.itemgetter(0)) oldAppToolDictSorted = sorted(oldAppToolDict.iteritems(), key=operator.itemgetter(0)) needsUpdate = (appToolDictSorted != oldAppToolDictSorted) if needsUpdate: appInfo["SMPrivilegedExecutables"] = appToolDict plistlib.writePlist(appInfo, appInfoPlistPath) print >> sys.stdout, "%s: updated" % appInfoPlistPath # Set the SMAuthorizedClients value in each tool's "Info.plist". toolAppListSorted = [ appReq ] # only one element, so obviously sorted (-: for toolInfoPlistPath in toolInfoPlistPaths: toolInfo = toolInfoPlistPathToToolInfoMap[toolInfoPlistPath] needsUpdate = not toolInfo.has_key("SMAuthorizedClients") if not needsUpdate: oldToolAppList = toolInfo["SMAuthorizedClients"] if not isinstance(oldToolAppList, list): raise CheckException("'SMAuthorizedClients' must be an array", toolInfoPlistPath) oldToolAppListSorted = sorted(oldToolAppList) needsUpdate = (toolAppListSorted != oldToolAppListSorted) if needsUpdate: toolInfo["SMAuthorizedClients"] = toolAppListSorted plistlib.writePlist(toolInfo, toolInfoPlistPath) print >> sys.stdout, "%s: updated" % toolInfoPlistPath def main(): options, appArgs = getopt.getopt(sys.argv[1:], "d") debug = False for opt, val in options: if opt == "-d": debug = True else: raise UsageException() if len(appArgs) == 0: raise UsageException() command = appArgs[0] if command == "check": if len(appArgs) != 2: raise UsageException() check(appArgs[1]) elif command == "setreq": if len(appArgs) < 4: raise UsageException() setreq(appArgs[1], appArgs[2], appArgs[3:]) else: raise UsageException() if __name__ == "__main__": try: main() except CheckException, e: if e.path is None: print >> sys.stderr, "%s: %s" % (os.path.basename(sys.argv[0]), e.message) else: path = e.path if path.endswith("/"): path = path[:-1] print >> sys.stderr, "%s: %s" % (path, e.message) sys.exit(1) except UsageException, e: print >> sys.stderr, "usage: %s check /path/to/app" % os.path.basename(sys.argv[0]) print >> sys.stderr, " %s setreq /path/to/app /path/to/app/Info.plist /path/to/tool/Info.plist..." % os.path.basename(sys.argv[0]) sys.exit(1)