From 5d83896080a98b1bd05fa97f11cb74594b0cedb3 Mon Sep 17 00:00:00 2001 From: yicheng <11733500+yichengchen@users.noreply.github.com> Date: Wed, 26 Apr 2023 14:57:43 +0800 Subject: [PATCH] ci: update SMJobBlessUtil.py from https://gist.github.com/mikeyh/89a1e2ecc6849ff6056b7391c5216799 --- .github/workflows/main.yml | 2 +- SMJobBlessUtil.py | 265 ++++++++++++++++++++----------------- 2 files changed, 143 insertions(+), 124 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e05a47b..82090dd 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -49,7 +49,7 @@ jobs: cd .. bundle exec fastlane build echo "Checking SMJobBless Vailded" - python SMJobBlessUtil.py check ClashX.app + python3 SMJobBlessUtil.py check ClashX.app echo "Check done" - name: setup node diff --git a/SMJobBlessUtil.py b/SMJobBlessUtil.py index fd54a50..349750f 100644 --- a/SMJobBlessUtil.py +++ b/SMJobBlessUtil.py @@ -1,20 +1,20 @@ -#! /usr/bin/python -# +#! /usr/bin/python3 +# # 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 @@ -32,14 +32,14 @@ # 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. +# +# 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, @@ -49,7 +49,7 @@ # 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 @@ -57,17 +57,18 @@ import getopt import subprocess import plistlib import operator +import platform class UsageException (Exception): """ - Raised when the progam detects a usage issue; the top-level code catches this + 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 + 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): @@ -77,38 +78,38 @@ class CheckException (Exception): 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 + # 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", + # "false", + "codesign", + "-v", "-v", programPath ] try: subprocess.check_call(args, stderr=open("/dev/null")) - except subprocess.CalledProcessError, e: + except subprocess.CalledProcessError as 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", - "-", + # "false", + "codesign", + "-d", + "-r", + "-", programPath ] try: - req = subprocess.check_output(args, stderr=open("/dev/null")) - except subprocess.CalledProcessError, e: + req = subprocess.check_output(args, stderr=open("/dev/null"), encoding="utf-8") + except subprocess.CalledProcessError as e: raise CheckException("%s designated requirement unreadable" % programType, programPath) reqLines = req.splitlines() @@ -119,77 +120,97 @@ def readDesignatedRequirement(programPath, programType): def readInfoPlistFromPath(infoPath): """Reads an "Info.plist" file from the specified path.""" try: - info = plistlib.readPlist(infoPath) + with open(infoPath, 'rb') as fp: + info = plistlib.load(fp) 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, + # "false", + "otool", + "-V", + "-arch", + platform.machine(), + "-s", + segmentName, + sectionName, toolPath ] try: - plistDump = subprocess.check_output(args) - except subprocess.CalledProcessError, e: + plistDump = subprocess.check_output(args, encoding="utf-8") + except subprocess.CalledProcessError as 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)): + # Convert that dump to an property list. + + plistLines = plistDump.strip().splitlines(keepends=True) + + if len(plistLines) < 3: raise CheckException("tool %s / %s section dump malformed (1)" % (segmentName, sectionName), toolPath) + + header = plistLines[1].strip() + + if not header.endswith("(%s,%s) section" % (segmentName, sectionName)): + raise CheckException("tool %s / %s section dump malformed (2)" % (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)) + + if header.startswith('Contents of'): + data = [] + for line in plistLines: + # line looks like this: + # + # '100000000 3c 3f 78 6d 6c 20 76 65 72 73 69 6f 6e 3d 22 31 |= 2 + del columns[0] + for hexStr in columns: + data.append(int(hexStr, 16)) + data = bytes(data) + else: + data = bytes("".join(plistLines), encoding="utf-8") + + plist = plistlib.loads(data) except: - raise CheckException("tool %s / %s section dump malformed (2)" % (segmentName, sectionName), toolPath) + raise CheckException("tool %s / %s section dump malformed (3)" % (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": @@ -200,50 +221,50 @@ def checkStep1(appPath): 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"): + if "SMPrivilegedExecutables" not in info: 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 + + # 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 + # 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])) @@ -252,29 +273,29 @@ 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. - + # 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": + if "CFBundleInfoDictionaryVersion" not in info 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): + if "CFBundleIdentifier" not in info 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"): + if "SMAuthorizedClients" not in info: 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 + + # 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: @@ -286,22 +307,22 @@ def checkStep4(appPath, toolPathList): for toolPath in toolPathList: launchd = readPlistFromToolSection(toolPath, "__TEXT", "__launchd_plist") - if not launchd.has_key("Label") or launchd["Label"] != os.path.basename(toolPath): + if "Label" not in launchd 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 + # 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) @@ -314,7 +335,7 @@ def check(appPath): def setreq(appPath, appInfoPlistPath, toolInfoPlistPaths): """ - Reads information from the built app and uses it to set the SMJobBless setup + Reads information from the built app and uses it to set the SMJobBless setup in the specified app and tool Info.plist source files. """ @@ -328,20 +349,16 @@ def setreq(appPath, appInfoPlistPath, toolInfoPlistPaths): 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 = {} - print os.listdir(toolDirPath) for toolName in os.listdir(toolDirPath): req = readDesignatedRequirement(os.path.join(toolDirPath, toolName), "tool") - print '-----' - print toolName - print '-----' toolNameToReqMap[toolName] = req if len(toolNameToReqMap) > len(toolInfoPlistPaths): @@ -350,58 +367,60 @@ def setreq(appPath, appInfoPlistPath, 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"): + if "CFBundleIdentifier" not in toolInfo: raise CheckException("'CFBundleIdentifier' not found", toolInfoPlistPath) bundleID = toolInfo["CFBundleIdentifier"] - if not isinstance(bundleID, basestring): + if not isinstance(bundleID, str): 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") + needsUpdate = "SMPrivilegedExecutables" not in appInfo 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)) + appToolDictSorted = sorted(appToolDict.items(), key=operator.itemgetter(0)) + oldAppToolDictSorted = sorted(oldAppToolDict.items(), key=operator.itemgetter(0)) needsUpdate = (appToolDictSorted != oldAppToolDictSorted) - + if needsUpdate: appInfo["SMPrivilegedExecutables"] = appToolDict - plistlib.writePlist(appInfo, appInfoPlistPath) - print >> sys.stdout, "%s: updated" % appInfoPlistPath - + with open(appInfoPlistPath, 'wb') as fp: + plistlib.dump(appInfo, fp) + print ("%s: updated" % appInfoPlistPath, file = sys.stdout) + # 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") + + needsUpdate = "SMAuthorizedClients" not in toolInfo 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 + with open(toolInfoPlistPath, 'wb') as f: + plistlib.dump(toolInfo, f) + print("%s: updated" % toolInfoPlistPath, file = sys.stdout) def main(): options, appArgs = getopt.getopt(sys.argv[1:], "d") - + debug = False for opt, val in options: if opt == "-d": @@ -426,16 +445,16 @@ def main(): if __name__ == "__main__": try: main() - except CheckException, e: + except CheckException as e: if e.path is None: - print >> sys.stderr, "%s: %s" % (os.path.basename(sys.argv[0]), e.message) + print("%s: %s" % (os.path.basename(sys.argv[0]), e.message), file = sys.stderr) 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]) + print("%s: %s" % (path, e.message), file = sys.stderr) sys.exit(1) + except UsageException as e: + print("usage: %s check /path/to/app" % os.path.basename(sys.argv[0]), file = sys.stderr) + print(" %s setreq /path/to/app /path/to/app/Info.plist /path/to/tool/Info.plist..." % os.path.basename(sys.argv[0]), file = sys.stderr) + sys.exit(1) \ No newline at end of file