SMJobBlessUtil.py
#! /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) |
Copyright © 2013 Apple Inc. All Rights Reserved. Terms of Use | Privacy Policy | Updated: 2013-09-17