diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index b0e768b..759ce92 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -32,15 +32,14 @@ files: '(jamf|jss)/extension.?attributes/.*\.(sh|bash|py|rb|js|pl)$' types: [text] -- id: check-jamf-scripts - name: Check Jamf Scripts - description: This hook checks Jamf scripts for common issues. - entry: check-jamf-scripts - language: python - # Switch from files regex to "OR" types when that feature is available: - # https://github.com/pre-commit/pre-commit/issues/607 - files: '(jamf|jss)/scripts/.*\.(sh|bash|py|rb|js|pl)$' - types: [text] +# WORK IN PROGRESS +# - id: check-jamf-json-manifests +# name: Check Jamf JSON Manifests +# description: This hook checks Jamf JSON manifests for inconsistencies and common issues. +# entry: check-jamf-json-manifests +# language: python +# files: '\.json$' +# types: [text] - id: check-jamf-profiles name: Check Jamf Profiles @@ -52,12 +51,22 @@ files: '(jamf|jss)/profiles/.*\.(mobileconfig|plist)$' types: [text] +- id: check-jamf-scripts + name: Check Jamf Scripts + description: This hook checks Jamf scripts for common issues. + entry: check-jamf-scripts + language: python + # Switch from files regex to "OR" types when that feature is available: + # https://github.com/pre-commit/pre-commit/issues/607 + files: '(jamf|jss)/scripts/.*\.(sh|bash|py|rb|js|pl)$' + types: [text] + - id: check-munki-pkgsinfo name: Check Munki Pkginfo Files description: This hook checks Munki pkginfo files to ensure they are valid. entry: check-munki-pkgsinfo language: python - files: '^pkgsinfo/' + files: "^pkgsinfo/" types: [text] - id: check-munkiadmin-scripts @@ -65,7 +74,7 @@ description: This hook ensures MunkiAdmin scripts are executable. entry: check-munkiadmin-scripts language: python - files: '^MunkiAdmin/scripts/' + files: "^MunkiAdmin/scripts/" types: [text] - id: check-munkipkg-buildinfo @@ -81,7 +90,7 @@ description: This hook checks Outset scripts to ensure they're executable. entry: check-outset-scripts language: python - files: 'usr/local/outset/(boot-once|boot-every|login-once|login-every|login-privileged-once|login-privileged-every|on-demand)/' + files: "usr/local/outset/(boot-once|boot-every|login-once|login-every|login-privileged-once|login-privileged-every|on-demand)/" types: [text] - id: check-plists @@ -92,6 +101,14 @@ files: '\.(plist|recipe|mobileconfig|pkginfo)$' types: [text] +- id: check-preference-manifests + name: Check Apple Preference Manifests + description: This hook checks preference manifest plists for inconsistencies and common issues. + entry: check-preference-manifests + language: python + files: '\.plist$' + types: [text] + - id: forbid-autopkg-overrides name: Forbid AutoPkg Overrides description: This hook prevents AutoPkg overrides from being added to the repo. diff --git a/CHANGELOG.md b/CHANGELOG.md index e07df90..4c2b213 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,13 @@ All notable changes to this project will be documented in this file. This projec Nothing yet. +## [1.12.0] - 2021-12-19 + +### Added + +- New `check-preference-manifests` hook for checking Apple preference manifests like those used by ProfileCreator and iMazing Profile Editor [manifests](https://github.com/ProfileCreator/ProfileManifests). +- Check for the [recommended order](https://youtu.be/srz4U9RHliQ?list=PLlxHm_Px-Ie1EIRlDHG2lW5H7c2UYvops&t=1010) of JamfUploader processors. + ## [1.11.0] - 2021-11-20 ### Added @@ -262,7 +269,8 @@ Nothing yet. - Initial release -[Unreleased]: https://github.com/homebysix/pre-commit-macadmin/compare/v1.11.0...HEAD +[Unreleased]: https://github.com/homebysix/pre-commit-macadmin/compare/v1.12.0...HEAD +[1.12.0]: https://github.com/homebysix/pre-commit-macadmin/compare/v1.11.0...v1.12.0 [1.11.0]: https://github.com/homebysix/pre-commit-macadmin/compare/v1.10.1...v1.11.0 [1.10.1]: https://github.com/homebysix/pre-commit-macadmin/compare/v1.9.0...v1.10.1 [1.9.0]: https://github.com/homebysix/pre-commit-macadmin/compare/v1.8.2...v1.9.0 diff --git a/README.md b/README.md index e8aba14..9cbf82c 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ For any hook in this repo you wish to use, add the following to your pre-commit ```yaml - repo: https://github.com/homebysix/pre-commit-macadmin - rev: v1.11.0 + rev: v1.12.0 hooks: - id: check-plists # - id: ... @@ -121,7 +121,7 @@ When combining arguments that take lists (for example: `--required-keys`, `--cat ```yaml - repo: https://github.com/homebysix/pre-commit-macadmin - rev: v1.11.0 + rev: v1.12.0 hooks: - id: check-munki-pkgsinfo args: ['--catalogs', 'testing', 'stable', '--'] @@ -131,7 +131,7 @@ But if you also use the `--categories` argument, you would move the trailing `-- ```yaml - repo: https://github.com/homebysix/pre-commit-macadmin - rev: v1.11.0 + rev: v1.12.0 hooks: - id: check-munki-pkgsinfo args: ['--catalogs', 'testing', 'stable', '--categories', 'Design', 'Engineering', 'Web Browsers', '--'] @@ -143,7 +143,7 @@ If it looks better to your eye, feel free to use a multi-line list for long argu ```yaml - repo: https://github.com/homebysix/pre-commit-macadmin - rev: v1.11.0 + rev: v1.12.0 hooks: - id: check-munki-pkgsinfo args: [ diff --git a/pre_commit_hooks/check_autopkg_recipes.py b/pre_commit_hooks/check_autopkg_recipes.py index e023bc0..152c78c 100755 --- a/pre_commit_hooks/check_autopkg_recipes.py +++ b/pre_commit_hooks/check_autopkg_recipes.py @@ -279,6 +279,46 @@ def validate_no_superclass_procs(process, filename): return passed +def validate_jamf_processor_order(process, filename): + """Warn if JamfUploader processors are not in their conventional order. + https://youtu.be/srz4U9RHliQ?list=PLlxHm_Px-Ie1EIRlDHG2lW5H7c2UYvops&t=1010 + """ + + # Recommended order of Jamf processors + rec_order = ( + "com.github.grahampugh.jamf-upload.processors/JamfCategoryUploader", + "com.github.grahampugh.jamf-upload.processors/JamfExtensionAttributeUploader", + "com.github.grahampugh.jamf-upload.processors/JamfPackageUploader", + "com.github.grahampugh.jamf-upload.processors/JamfScriptUploader", + "com.github.grahampugh.jamf-upload.processors/JamfComputerGroupUploader", + # TODO: The three below may depend on computer groups, but there's no + # easy way to ignore relative order if multiple are used. Focusing on + # JamfPolicyUploader only for now. + "com.github.grahampugh.jamf-upload.processors/JamfPolicyUploader", + # "com.github.grahampugh.jamf-upload.processors/JamfComputerProfileUploader", + # "com.github.grahampugh.jamf-upload.processors/JamfSoftwareRestrictionUploader", + ) + + passed = True + # All JamfUploader processors in recipe, ignoring duplicates, preserving order. + actual_order = list( + dict.fromkeys( + [x.get("Processor") for x in process if x.get("Processor") in rec_order] + ) + ) + desired_order = [x for x in rec_order if x in actual_order] + if desired_order != actual_order: + print( + "{}: WARNING: JamfUploader processors are not in " + "the recommended order: {}.".format( + filename, + ", ".join([x.split("/")[-1] for x in desired_order]), + ) + ) + + return passed + + # def validate_unused_input_vars(recipe, recipe_text, filename): # """Warn if any input variables are not referenced in the recipe.""" @@ -367,7 +407,6 @@ def validate_proc_type_conventions(process, filename): "com.github.grahampugh.jamf-upload.processors/JamfPolicyUploader", "com.github.grahampugh.jamf-upload.processors/JamfScriptUploader", "com.github.grahampugh.jamf-upload.processors/JamfSoftwareRestrictionUploader", - "com.github.grahampugh.jamf-upload.processors/JamfUploaderSlacker", ], # https://github.com/autopkg/filewave "filewave": ["FileWaveImporter"], @@ -610,6 +649,9 @@ def main(argv=None): if not validate_no_superclass_procs(process, filename): retval = 1 + if not validate_jamf_processor_order(process, filename): + retval = 1 + if HAS_AUTOPKGLIB: if not validate_proc_args(process, filename): retval = 1 diff --git a/pre_commit_hooks/check_jamf_json_manifests.py b/pre_commit_hooks/check_jamf_json_manifests.py new file mode 100755 index 0000000..65c6a36 --- /dev/null +++ b/pre_commit_hooks/check_jamf_json_manifests.py @@ -0,0 +1,256 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +"""This hook checks Jamf JSON schema custom app manifests for inconsistencies and common issues.""" + +# References: +# - https://docs.jamf.com/technical-papers/jamf-pro/json-schema/10.19.0/Understanding_the_Structure_of_a_JSON_Schema_Manifest.html +# - https://github.com/Jamf-Custom-Profile-Schemas + +import argparse +import json +from datetime import datetime + +from pre_commit_hooks.util import PLIST_TYPES, validate_required_keys + +# Types found in the Jamf JSON manifests +MANIFEST_TYPES = ( + "array", + "boolean", + "data", + "date", + "float", + "integer", + "number", + "object", + "real", + "string", +) + +# List keys and their expected item types +MANIFEST_LIST_TYPES = { + "enum_titles": str, + "enum": (str, int, float, bool), + "links": dict, + "anyOf": dict, +} + + +def build_argument_parser(): + """Build and return the argument parser.""" + + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument("filenames", nargs="*", help="Filenames to check.") + return parser + + +def validate_key_types(name, manifest, filename): + """Validation of manifest key types.""" + + # Manifest keys and their known types. Omitted keys are left unvalidated. + key_types = { + "description": str, + "enum_titles": list, + "enum": list, + "href": str, + "items": dict, + "links": list, + "options": dict, + "pattern": str, + "properties": dict, + "property_order": int, + "rel": str, + "title": str, + "type": str, + "anyOf": list, + } + + passed = True + for manifest_key, expected_type in key_types.items(): + if manifest_key in manifest: + if not isinstance(manifest[manifest_key], expected_type): + print( + "{}: {} key {} should be type {}, not type {}".format( + filename, + name, + manifest_key, + expected_type, + type(manifest[manifest_key]), + ) + ) + passed = False + + return passed + + +def validate_type(name, property, filename): + """Ensure property type keu is present and among expected values.""" + passed = True + type_found = None + + if "type" in property: + type_found = property.get("type") + elif "anyOf" in property: + for t in [x.get("type") for x in property["anyOf"]]: + if t != "null": + type_found = t + break + + if type_found not in MANIFEST_TYPES: + print('{}: Unexpected "{}" type "{}"'.format(filename, name, type_found)) + passed = False + + return passed, type_found + + +def validate_list_item_types(name, manifest, filename): + """Validation of list member items.""" + + passed = True + for name in MANIFEST_LIST_TYPES: + if name in manifest: + try: + actual_type = type(manifest[name][0]) + except IndexError: + # Probably an empty array; no way to validate items + continue + if isinstance(MANIFEST_LIST_TYPES[name], tuple): + desired_types = MANIFEST_LIST_TYPES[name] + else: + desired_types = [MANIFEST_LIST_TYPES[name]] + if actual_type not in desired_types: + print( + '{}: "{}" items should be {}, not {}'.format( + filename, name, MANIFEST_LIST_TYPES[name], actual_type + ) + ) + passed = False + + return passed + + +def validate_default(name, property, type_found, filename): + """Ensure that default values have the expected type.""" + passed = True + + for test_key in ("default",): + if test_key in property: + if type(property[test_key]) == datetime: + actual_type = str + else: + actual_type = type(property[test_key]) + if actual_type != PLIST_TYPES[type_found]: + print( + "{}: {} value for {} should be {}, not {}".format( + filename, + test_key, + name, + PLIST_TYPES[type_found], + type(property[test_key]), + ) + ) + passed = False + + return passed + + +def validate_urls(name, property, filename): + """Ensure that URL values are actual URLs.""" + passed = True + + url_keys = ("pfm_app_url", "pfm_documentation_url") + for url_key in url_keys: + if url_key in property: + if not property[url_key].startswith("http"): + print( + "{}: {} {} value doesn't look like a URL: {}".format( + filename, + name, + url_key, + property[url_key], + ) + ) + passed = False + + return passed + + +def validate_properties(properties, filename): + """Given a list of properties, run validation on their contents.""" + passed = True + + for name, prop in properties.items(): + if name.strip() == "": + name = "" + + # Validate URLs + if not validate_urls(name, prop, filename): + passed = False + + # Check for presence of "type" key. + type_ok, type_found = validate_type(name, prop, filename) + if not type_ok: + passed = False + break # No need to continue checking this property + + # Check that list items are of the expected type + if not validate_list_item_types(name, prop, filename): + passed = False + + # Check default values to ensure consistent type + if not validate_default(name, prop, type_found, filename): + passed = False + + # TODO: Validate pfm_conditionals + # https://github.com/ProfileCreator/ProfileManifests/wiki/Manifest-Format#example-conditions--exclusions + + # TODO: Process $ref references + + # Recursively validate sub-sub-properties + if "properties" in prop: + if not validate_properties(prop["properties"], filename): + passed = False + + return passed + + +def main(argv=None): + """Main process.""" + + # Parse command line arguments. + argparser = build_argument_parser() + args = argparser.parse_args(argv) + + retval = 0 + for filename in args.filenames: + try: + with open(filename, "rb") as openfile: + manifest = json.load(openfile) + except json.decoder.JSONDecodeError as err: + print("{}: json parsing error: {}".format(filename, err)) + retval = 1 + break # No need to continue checking this file + + # Check for presence of required keys. + required_keys = ("title", "properties", "description") + if not validate_required_keys(manifest, filename, required_keys): + retval = 1 + break # No need to continue checking this file + + # Ensure top level keys and their list items have expected types. + if not validate_key_types("", manifest, filename): + retval = 1 + if not validate_list_item_types("", manifest, filename): + retval = 1 + + # Run checks recursively for all properties + if "properties" in manifest: + if not validate_properties(manifest["properties"], filename): + retval = 1 + + return retval + + +if __name__ == "__main__": + exit(main()) diff --git a/pre_commit_hooks/check_preference_manifests.py b/pre_commit_hooks/check_preference_manifests.py new file mode 100755 index 0000000..c8aae5a --- /dev/null +++ b/pre_commit_hooks/check_preference_manifests.py @@ -0,0 +1,429 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +"""This hook checks Apple preference manifest plists for inconsistencies and common issues.""" + +# References: +# - https://developer.apple.com/library/archive/documentation/MacOSXServer/Conceptual/Preference_Manifest_Files/Preface/Preface.html +# - https://github.com/ProfileCreator/ProfileManifests/wiki/Manifest-Format +# - https://mosen.github.io/profiledocs/manifest.html + +import argparse +import plistlib +from datetime import datetime +from xml.parsers.expat import ExpatError + +from pre_commit_hooks.util import PLIST_TYPES + +# List keys and their expected item types +PFM_LIST_TYPES = { + "pfm_allowed_file_types": str, + "pfm_conditionals": dict, + "pfm_exclude": dict, + "pfm_subkeys": dict, + "pfm_target_conditions": str, + "pfm_targets": str, + "pfm_upk_input_keys": str, + "pfm_n_platforms": str, + "pfm_platforms": str, + "pfm_range_list_titles": str, +} + + +def build_argument_parser(): + """Build and return the argument parser.""" + + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument("filenames", nargs="*", help="Filenames to check.") + return parser + + +def validate_required_keys(input_dict, required_keys, dict_name, filename): + """Verifies that required_keys are present in dictionary.""" + passed = True + for req_key in required_keys: + if not input_dict.get(req_key): + print("{}: {} missing required key {}".format(filename, dict_name, req_key)) + passed = False + return passed + + +def validate_manifest_key_types(manifest, filename): + """Validation of manifest key types.""" + + # manifest keys and their known types. Omitted keys are left unvalidated. + # Last updated 2021-12-03. + key_types = { + "pfm_conditionals": list, + "pfm_description": str, + "pfm_domain": str, + "pfm_exclude": list, + "pfm_format_version": (int, float), + "pfm_format": str, + "pfm_name": str, + "pfm_range_list": list, + "pfm_range_max": (int, float), + "pfm_range_min": (int, float), + "pfm_repetition_max": int, + "pfm_repetition_min": int, + "pfm_require": str, + "pfm_required": bool, + "pfm_subkeys": list, + "pfm_targets": list, + "pfm_title": str, + "pfm_type": str, + "pfm_version": (int, float), + # Extended manifest format keys: + "pfm_allowed_file_types": list, + "pfm_app_deprecated": str, + "pfm_app_max": str, + "pfm_app_min": str, + "pfm_app_url": str, + "pfm_default_copy": str, + "pfm_date_allow_past": bool, + "pfm_date_style": str, + "pfm_description_extended": str, + "pfm_description_reference": str, + "pfm_documentation_url": str, + "pfm_enabled": bool, + "pfm_excluded": bool, + "pfm_hidden": str, + # "pfm_icon": data, + "pfm_interaction": str, + "pfm_ios_deprecated": str, + "pfm_ios_max": str, + "pfm_ios_min": str, + "pfm_last_modified": datetime, + "pfm_macos_deprecated": str, + "pfm_macos_max": str, + "pfm_macos_min": str, + "pfm_note": str, + "pfm_n_platforms": list, + "pfm_platforms": list, + "pfm_range_list_allow_custom_value": bool, + "pfm_range_list_titles": list, + "pfm_segments": dict, + "pfm_sensitive": bool, + "pfm_subdomain": str, + "pfm_substitution_variables": dict, + "pfm_supervised": bool, + "pfm_type_input": str, + "pfm_tvos_deprecated": str, + "pfm_tvos_max": str, + "pfm_tvos_min": str, + "pfm_unique": bool, + "pfm_user_approved": bool, + "pfm_value_copy": str, + "pfm_value_decimal_places": int, + "pfm_value_inverted": bool, + "pfm_value_import_processor": str, + "pfm_value_info_processor": str, + "pfm_value_processor": str, + "pfm_value_unique": bool, + "pfm_value_unit": str, + "pfm_view": str, + } + + passed = True + for manifest_key, expected_type in key_types.items(): + if manifest_key in manifest: + if not isinstance(manifest[manifest_key], expected_type): + print( + "{}: manifest key {} should be type {}, not type {}".format( + filename, + manifest_key, + expected_type, + type(manifest[manifest_key]), + ) + ) + passed = False + + return passed + + +def validate_list_item_types(manifest, filename): + """Validation of list member items.""" + + passed = True + for name in PFM_LIST_TYPES: + if name in manifest: + try: + actual_type = type(manifest[name][0]) + except IndexError: + # Probably an empty array; no way to validate items + continue + if actual_type is not PFM_LIST_TYPES[name]: + print( + '{}: "{}" items should be type {}, not type {}'.format( + filename, name, PFM_LIST_TYPES[name], actual_type + ) + ) + passed = False + + return passed + + +def validate_pfm_type_strings(subkey, filename): + """Ensure subkey pfm_type strings are as expected.""" + passed = True + + pfm_depr_types = ("union policy", "url") + if subkey["pfm_type"] in pfm_depr_types: + print( + '{}: WARNING: Subkey type "{}" is deprecated'.format( + filename, subkey["pfm_type"] + ) + ) + # passed = False + elif subkey["pfm_type"] not in PLIST_TYPES: + print('{}: Unexpected subkey type "{}"'.format(filename, subkey["pfm_type"])) + passed = False + + return passed + + +def validate_subkey_known_types(subkey, filename): + """Ensure specific subkey names have expected type.""" + passed = True + + pfm_name_types = { + "PayloadCertificateAnchorUUID": "array", + "PayloadCertificateFileName": "string", + "PayloadCertificateUUID": "string", + "PayloadContent": ("data", "string", "dictionary", "dict"), + "PayloadDescription": "string", + "PayloadDisplayName": "string", + "PayloadExpirationDate": "date", + "PayloadIdentification": ("dictionary", "dict"), + "PayloadIdentifier": "string", + "PayloadOrganization": "string", + "PayloadRemovalDisallowed": "boolean", + "PayloadType": "string", + "PayloadUUID": "string", + "PayloadVersion": ("integer", "float"), + } + if subkey.get("pfm_name", "") in pfm_name_types: + if isinstance(pfm_name_types[subkey["pfm_name"]], tuple): + name_types = pfm_name_types[subkey["pfm_name"]] + else: + name_types = [pfm_name_types[subkey["pfm_name"]]] + if subkey["pfm_type"] not in name_types: + print( + '{}: Subkey name "{}" should be type "string", not type "{}"'.format( + filename, subkey["pfm_name"], subkey["pfm_type"] + ) + ) + passed = False + + return passed + + +def validate_pfm_required(subkey, filename): + """Ensure pfm_require and pfm_required keys have expected values.""" + passed = True + + # Source: https://github.com/ProfileCreator/ProfileManifests/wiki/Manifest-Format + require_options = ("always", "always-nested", "push") + if "pfm_require" in subkey: + if subkey["pfm_require"] not in require_options: + print( + '{}: "pfm_require" value "{}" should be one of: {}'.format( + filename, subkey["pfm_require"], require_options + ) + ) + passed = False + if "pfm_required" in subkey: + if subkey["pfm_required"] is not True: + print( + '{}: "pfm_required" value "{}" should be True, if used at all'.format( + filename, subkey["pfm_required"] + ) + ) + passed = False + if "pfm_required" in subkey and "pfm_require" in subkey: + print( + '{}: No need to specify both "pfm_required" and "pfm_require"'.format( + filename + ) + ) + + return passed + + +def validate_pfm_targets(subkey, filename): + """Ensure pfm_targets key has expected values.""" + passed = True + + target_options = ("user", "user-managed", "system", "system-managed") + if "pfm_targets" in subkey: + if any([x not in target_options for x in subkey["pfm_targets"]]): + print( + '{}: "pfm_targets" values should be one of: {}'.format( + filename, target_options + ) + ) + passed = False + + return passed + + +def validate_pfm_default(subkey, filename): + """Ensure that default values have the expected type.""" + passed = True + + if "pfm_type" in subkey: + # TODO: Should we validate pfm_value_placeholder here too? + for test_key in ("pfm_default",): + if test_key in subkey: + # TODO: Should the default for list types be the type of the first list item, or itself? + # if PLIST_TYPES[subkey["pfm_type"]] == list: + # try: + # desired_type = type(subkey["pfm_subkeys"][0]) + # except IndexError: + # # Unknown desired type + # continue + # else: + desired_type = PLIST_TYPES[subkey["pfm_type"]] + if type(subkey[test_key]) != desired_type: + print( + "{}: {} value for {} should be type {}, not type {}".format( + filename, + test_key, + subkey.get("pfm_name"), + PLIST_TYPES[subkey["pfm_type"]], + type(subkey[test_key]), + ) + ) + passed = False + + return passed + + +def validate_urls(subkey, filename): + """Ensure that URL values are actual URLs.""" + passed = True + + url_keys = ("pfm_app_url", "pfm_documentation_url") + for url_key in url_keys: + if url_key in subkey: + if not subkey[url_key].startswith("http"): + print( + "{}: {} value doesn't look like a URL: {}".format( + filename, + url_key, + subkey[url_key], + ) + ) + passed = False + + return passed + + +def validate_subkeys(subkeys, filename): + """Given a list of subkeys, run validation on their contents.""" + passed = True + + for subkey in subkeys: + + # Check for presence of required keys. + required_keys = ("pfm_type",) + if not validate_required_keys( + subkey, required_keys, subkey.get("pfm_name", ""), filename + ): + passed = False + break # No need to continue checking this list of subkeys + + # Check for rogue pfm_type strings and deprecated keys. + if not validate_pfm_type_strings(subkey, filename): + passed = False + + # Check that list items are of the expected type + if "pfm_type" not in subkey: + print( + "WARNING: Recommend adding a pfm_title to %s" + % subkey.get("pfm_name", "") + ) + + # Check that list items are of the expected type + if not validate_list_item_types(subkey, filename): + passed = False + + # Check for specific names to have specific types + if not validate_subkey_known_types(subkey, filename): + passed = False + + # Check for specific key names to match specific values + if not validate_pfm_required(subkey, filename): + passed = False + if not validate_pfm_targets(subkey, filename): + passed = False + + # Check default values to ensure consistent type + if not validate_pfm_default(subkey, filename): + passed = False + + # Validate URLs + if not validate_urls(subkey, filename): + passed = False + + # TODO: Validate pfm_conditionals + # https://github.com/ProfileCreator/ProfileManifests/wiki/Manifest-Format#example-conditions--exclusions + + # Recursively validate sub-sub-keys + if "pfm_subkeys" in subkey: + if not validate_subkeys(subkey["pfm_subkeys"], filename): + passed = False + + return passed + + +def main(argv=None): + """Main process.""" + + # Parse command line arguments. + argparser = build_argument_parser() + args = argparser.parse_args(argv) + + retval = 0 + for filename in args.filenames: + try: + with open(filename, "rb") as openfile: + manifest = plistlib.load(openfile) + except (ExpatError, ValueError) as err: + print("{}: plist parsing error: {}".format(filename, err)) + retval = 1 + + # Check for presence of required keys. + required_keys = ("pfm_title", "pfm_domain", "pfm_description") + if not validate_required_keys(manifest, required_keys, "", filename): + retval = 1 + break # No need to continue checking this file + + # Ensure pfm_format_version has expected value + if manifest.get("pfm_format_version", 1) != 1: + print( + "{}: pfm_format_version should be 1, not {} " + "(https://github.com/ProfileCreator/ProfileManifests" + "/wiki/Manifest-Format-Versions)".format( + filename, manifest.get("pfm_format_version") + ) + ) + retval = 1 + + # Ensure top level keys and their list items have expected types. + if not validate_manifest_key_types(manifest, filename): + retval = 1 + if not validate_list_item_types(manifest, filename): + retval = 1 + + # Run checks recursively for all subkeys + if "pfm_subkeys" in manifest: + if not validate_subkeys(manifest["pfm_subkeys"], filename): + retval = 1 + + return retval + + +if __name__ == "__main__": + exit(main()) diff --git a/pre_commit_hooks/util.py b/pre_commit_hooks/util.py index 2195ff1..f14962d 100644 --- a/pre_commit_hooks/util.py +++ b/pre_commit_hooks/util.py @@ -8,6 +8,20 @@ from ruamel import yaml +# Plist data types and their Python equivalents +PLIST_TYPES = { + "string": str, + "boolean": bool, + "dict": dict, + "dictionary": dict, + "integer": int, + "array": list, + "data": None, # TODO: How to represent this? + "float": float, + "real": float, + "date": datetime, +} + def load_autopkg_recipe(path): """Loads an AutoPkg recipe in plist, yaml, or json format.""" @@ -38,18 +52,18 @@ def load_autopkg_recipe(path): return recipe -def validate_required_keys(plist, filename, required_keys): - """Verifies that required_keys are present in dictionary plist.""" +def validate_required_keys(input_dict, filename, required_keys): + """Verifies that required_keys are present in dictionary.""" passed = True for req_key in required_keys: - if not plist.get(req_key): + if not input_dict.get(req_key): print("{}: missing required key {}".format(filename, req_key)) passed = False return passed def validate_restart_action_key(pkginfo, filename): - """Verifies that required_keys are present in dictionary plist.""" + """Verifies that required_keys are present in pkginfo dictionary.""" passed = True allowed_values = ( "RequireShutdown", diff --git a/setup.py b/setup.py index d2a6ab0..8acc610 100755 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ name="pre-commit-macadmin", description="Pre-commit hooks for Mac admins, client engineers, and IT consultants.", url="https://github.com/homebysix/pre-commit-macadmin", - version="1.11.0", + version="1.12.0", author="Elliot Jordan", author_email="elliot@elliotjordan.com", packages=["pre_commit_hooks"], @@ -18,13 +18,15 @@ "check-autopkg-recipes = pre_commit_hooks.check_autopkg_recipes:main", "check-git-config-email = pre_commit_hooks.check_git_config_email:main", "check-jamf-extension-attributes = pre_commit_hooks.check_jamf_extension_attributes:main", - "check-jamf-scripts = pre_commit_hooks.check_jamf_scripts:main", + # "check-jamf-json-manifests = pre_commit_hooks.check_jamf_json_manifests:main", "check-jamf-profiles = pre_commit_hooks.check_jamf_profiles:main", + "check-jamf-scripts = pre_commit_hooks.check_jamf_scripts:main", "check-munki-pkgsinfo = pre_commit_hooks.check_munki_pkgsinfo:main", "check-munkiadmin-scripts = pre_commit_hooks.check_munkiadmin_scripts:main", "check-munkipkg-buildinfo = pre_commit_hooks.check_munkipkg_buildinfo:main", "check-outset-scripts = pre_commit_hooks.check_outset_scripts:main", "check-plists = pre_commit_hooks.check_plists:main", + "check-preference-manifests = pre_commit_hooks.check_preference_manifests:main", "forbid-autopkg-overrides = pre_commit_hooks.forbid_autopkg_overrides:main", "forbid-autopkg-trust-info = pre_commit_hooks.forbid_autopkg_trust_info:main", "munki-makecatalogs = pre_commit_hooks.munki_makecatalogs:main",