438 lines
18 KiB
Python
Executable File
438 lines
18 KiB
Python
Executable File
#! /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)
|