├── .gitignore ├── LICENSE ├── README.md ├── macos-update-processor.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Matt Hrono 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # macOS Update Processor 2 | __Keep your macOS devices up to date using Declarative Device Management, Jamf Pro, and a SOFA feed.__ 3 | 4 | ## Overview 5 | 6 | Let's be real. Keeping macOS devices up to date at scale isn't fun. I know I'm not the only one still occasionally pining for the days of sending `softwareupdate -aiR` over ARD and calling it a day. Unfortunately, those days are gone. Fortunately, this can get close! 7 | 8 | Given appropriate API credentials to your jamf tenant, this script will find outdated devices, ensure they're eligible for DDM-based update enforcement, and handle all the dirty work for you. 9 | 10 | ## Requirements 11 | In order for this script to run successfully, you'll need the following: 12 | - Jamf Pro v11.9.1 or later 13 | - API Credentials for your jamf tenant with the following permissions: 14 | - Read Computers 15 | - Create/Read/Update/Delete Managed Software Updates 16 | - Read Smart Computer Groups 17 | - Read Static Computer Groups 18 | - Send Computer Remote Command to Download and Install OS X Update 19 | - Python v3.12 or later (3.10+ may work, but only tested on 3.12) 20 | - Additional modules detailed in requirements.txt -- I suggest the "recommended" flavor of MacAdmins Python: https://github.com/macadmins/python 21 | - This recommended Python package also includes any other modules this script requires ***(EXCEPT for jamf-pro-sdk)*** 22 | - Be sure to update the shebang to point to your managed installation 23 | 24 | ## Acknowledgements 25 | 26 | Big thanks to the creators and maintainers of SOFA (https://sofa.macadmins.io/), without whom this project would not be possible 27 | Huge thanks as well to the creators and maintainers of jamf-pro-sdk (https://github.com/macadmins/jamf-pro-sdk-python), also without whom this project would not be possible 28 | 29 | ## Known Issues/Deficiencies 30 | 31 | - This script is currently hardcoded for macOS updates only. It could be modified to also support updating iOS devices. I may do this in the future, but it is not currently planned. 32 | - The sendNotifications function is planned but not currently implemented. 33 | - The `--check` and `--retry` arguments have not been tested and may not be functional. Use at your own risk. 34 | - Canary --> general deployment flows have not been tested and may not be functional. Use at your own risk. 35 | 36 | ## Usage 37 | 38 | ### I don't want to read docs. Just tell me how to make it go. 39 | The only required arguments are your jamf URL and credentials. This will YOLO the shiniest release from Cupertino to all eligible devices. 40 | 41 | With that said, I recommend at least checking out the `--targetversion` argument for a bit more control. 42 | 43 | ### Arguments 44 | 45 | __REQUIRED__ 46 | - `--jamfurl`: URL for the target jamf instance -- protocol prefix not required (ex: org.jamfcloud.com) 47 | - `--clientid`: Jamf Pro API Client ID 48 | - `--clientsecret`: Jamf Pro API Client Secret 49 | 50 | __OPTIONAL__ 51 | 52 | _Prior Run Metadata Handling_ 53 | - `--check`: Read existing plan data from file and update with the latest results 54 | - `--retry`: After checking existing plan data, retry any failed plans. Use of this option implies --check. 55 | **CAUTION**: Retries will re-use existing installation deadlines. This could result in devices restarting for updates with little to no warning. 56 | Retries for exceeded installation deadlines will receive a new deadline of 3 days. 57 | 58 | _CVE Checking_ 59 | - `--nvdtoken`: API key for NIST NVD. Not required, but providing one will enable faster CVE processing due to higher rate limits. 60 | **NOTE**: Using VulnCheck is strongly recommended over NVD due to ongoing issues with NIST update timeliness. 61 | - `--vulnchecktoken`: API key for VulnCheck (https://vulncheck.com) 62 | 63 | _SOFA Options_ 64 | - `--feedsource`: Full path or URL to a SOFA-generated macos_data_feed.json file. Defaults to https://sofafeed.macadmins.io/v1/macos_data_feed.json 65 | - `--timestamp`: Full path or URL to a SOFA-generated timestamp.json file. Defaults to https://sofafeed.macadmins.io/v1/timestamp.json 66 | 67 | _Target macOS Version_ 68 | - `--targetversion`: Target macOS version for deployment. Can be any of the following: 69 | 70 | - Specific Version -- A specific macOS version to target for ALL eligible devices (e.g. 14.7.1) | Use --overridegroup and/or --excludegroup to target subsets of devices 71 | - "ANY" (default) -- The latest and greatest Cupertino has to offer for ALL eligible devices 72 | - "MAJOR" -- Target ONLY devices running the latest major version of macOS (e.g. updates devices on macOS 15 to the latest release of macOS 15) 73 | - "MINOR" -- Target devices running the 2 latest major versions of macOS for their respective latest releases (e.g. 14.x to latest 14 and 15.x to latest 15) 74 | 75 | _Device Scoping Options_ 76 | - `--excludegroup`: Name of a Smart/Static Computer Group containing devices to EXCLUDE from automated updates (such as conference room devices) 77 | - `--overridegroup`: Name of a Smart/Static Computer Group to target for updates (overrides default outdated group) 78 | - `--canarygroup`: Name of a Smart/Static Computer Group containing devices to always receive a 2-day installation deadline. 79 | **NOTE**: Canary deployments are __NOT__ currently compatible with --targetversion "MINOR". 80 | 81 | _Canary Deployment Options_ 82 | - `--canaryversion`: macOS ProductVersion deployed to canary group. Used to ensure the same version is deployed fleetwide. 83 | - `--canaryok`: Deploy macOS update fleetwide, assuming successful canary deployment 84 | 85 | _Deadline Options_ 86 | - `--canarydeadline`: Number of days before deadline for the canary group (Default: 2) 87 | - `--urgentdeadline`: Force the update to all outdated devices with the specified deadline (in days), if the aggregate CVE scores warrant accelerated deployment (Default: 7) 88 | - `--deadline`: Force the update to all outdated devices with the specified deadline (in days) (Default: 14) 89 | - __Not Implemented__ `--force`: Force the update to all outdated devices with the specified deadline (in days), overriding any configured canary data 90 | 91 | _Other Arguments_ 92 | - `--debug`: Enable debug logging for this script 93 | - `--dryrun`: Output proposed actions without executing any changes 94 | - `--datafile`: Full path or filename for storing plan data (defaults to current working directory) 95 | - `--version`: Show script version and exit 96 | 97 | ### Examples 98 | 99 | _All examples below assume jamf url and credentials arguments are also provided_ 100 | 101 | `--targetversion MAJOR --excludegroup "Zoom Room Devices"`: Dynamically determines an installation deadline based on CVE data in the latest macOS release, and sends DDM update plans to all devices running the latest major version of macOS, except for devices in the "Zoom Room Devices" group 102 | 103 | `--targetversion 14.7.1 --overridegroup "Conference Rooms" --deadline 4`: Finds all devices not running at least macOS 14.7.1 that are also in the "Conferece Rooms" group, and sends DDM update plans to those devices with a 4 day deadline 104 | 105 | `--targetversion MINOR --deadline 21 --dryrun`: Determines the latest releases of both the current and prior major macOS versions, and sends DDM update plans with a 21 day deadline to eligible devices according to their current major version (e.g. devices will not be upgraded from one major version to the next) 106 | This example also includes the `--dryrun` argument, which will only output what _would_ be deployed, but no update plans will actually be created. 107 | 108 | ## How It Works 109 | 110 | When run, this script does the following: 111 | - Validates that DDM updates are enabled in your jamf instance 112 | - Parses and validates SOFA feed data 113 | - Based on provided arguments, determines target macOS version(s) 114 | - Ensures jamf is aware of the target version(s) and offers them for deployment 115 | - If a custom deadline has _not_ been configured, CVEs patched in the target macOS version(s) are checked for their impact and exploitability scores. All scores are averaged. 116 | - If the average exploitability score is greater than 6, OR 117 | - The average impact score is greater than 8, THEN 118 | - The installation deadline is set to the value of `--urgentdeadline` (Default: 7) 119 | - Finds devices in-scope for the target macOS version(s), and filters the list further if any group arguments are specified 120 | - Checks target devices for DDM update eligibility. A device is eligible if ALL of the following are true: 121 | - The device must have DDM enabled (General > Declarative Device Management Enabled) 122 | - The device must have an escrowed bootstrap token (Security > Bootstrap Token Escrowed) 123 | - The device must be running macOS Sonoma (14) or later (Operating System > Version) 124 | - The device must support the target macOS version (Operating System > Software Update Device ID) 125 | - Checks target devices for any existing update plans in progress 126 | - As of Dec 3, 2024, jamf does not offer the ability to cancel individual update plans, even if they've appeared to fail (such as an installation deadline exceeded). Instead, the DDM update functionality must be completely disabled and re-enabled to clear existing plans, which clears ALL existing plans. I'd really appreciate an upvote on feature request [JPRO-I-336](https://ideas.jamf.com/ideas/JPRO-I-336) so we can get this functionality! 127 | - Because of the above, devices with plans in progress cannot have new plans sent to them 128 | - Finally, sends the update plans to in-scope eligible devices, verifies their successful (or not) deployment, and outputs a run summary 129 | 130 | ### Installation Deadlines 131 | 132 | While jamf has the ability to send macOS updates with deferrals instead of a deadline date and time, I didn't include that functionality. If you'd like to see it, please open an issue (or comment/upvote if one already exists). 133 | 134 | The same is true for the Download > Install > Restart method. I'm more likely to build this option at some point, but it's not currently planned. 135 | 136 | Deadlines are specified in a number of days (relative to the current date). Work is planned to use either a relative deadline or days since target version release. 137 | 138 | **Good to Know** 139 | - Installation deadlines are hardcoded for 7PM on the deadline date. This time is local to the device, so no need to worry about accounting for time zones! 140 | - If a configured deadline falls on a weekend day (Saturday or Sunday), it is automatically extended to the following Monday. 141 | - It is not currently possible to specify a deadline of `0` days. 142 | 143 | ## User Experience 144 | The great thing about using DDM for macOS update enforcement is that anything user-facing is strictly native macOS behavior. 145 | 146 | Apple's [Platform Deployment Guide](https://support.apple.com/guide/deployment/installing-and-enforcing-software-updates-depd30715cbb/1/web/1.0) has a great overview of what users should expect to see when DDM update declarations have been sent to their device. Check out the `Enforcing software updates` section for a really useful flowchart detailing when and how often users are notified about an upcoming deadline. 147 | 148 | ## Bugs and Feature Requests 149 | 150 | For any issues encountered or feature requests, please open an issue. 151 | 152 | Pull requests are also welcome for both of the above. 153 | 154 | ### Reminder 155 | 156 | This repo is licensed under an MIT license. Please review the [`LICENSE`](https://github.com/mhrono/jamf-sofa-processor/blob/main/LICENSE) carefully before using this script. 157 | -------------------------------------------------------------------------------- /macos-update-processor.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | macOS Update Processor 5 | Keep your macOS devices up to date using Declarative Device Management, Jamf Pro, and a SOFA feed. 6 | 7 | Author: Matt Hrono @ Chime | MacAdmins: @matt_h | mattonmacs.dev 8 | 9 | ---- 10 | MIT License 11 | 12 | Permission is hereby granted, free of charge, to any person obtaining a copy 13 | of this software and associated documentation files (the "Software"), to deal 14 | in the Software without restriction, including without limitation the rights 15 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 16 | copies of the Software, and to permit persons to whom the Software is 17 | furnished to do so, subject to the following conditions: 18 | 19 | The above copyright notice and this permission notice shall be included in all 20 | copies or substantial portions of the Software. 21 | 22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 23 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 24 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 25 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 26 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 27 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 28 | SOFTWARE. 29 | ---- 30 | 31 | REQUIREMENTS: 32 | 33 | - Jamf Pro v11.9.1 or later 34 | - API Credentials for your jamf tenant with the following permissions: 35 | - Read Computers 36 | - Create/Read/Update/Delete Managed Software Updates 37 | - Read Smart Computer Groups 38 | - Read Static Computer Groups 39 | - Send Computer Remote Command to Download and Install OS X Update 40 | - Python v3.12 or later (3.10+ may work, but only tested on 3.12) 41 | - Additional modules detailed in requirements.txt -- I suggest the "recommended" flavor of MacAdmins Python: https://github.com/macadmins/python 42 | - This recommended Python package also includes any other modules this script requires ***(EXCEPT for jamf-pro-sdk)*** 43 | - Be sure to update the shebang to point to your managed installation 44 | 45 | ACKNOWLEDGEMENTS: 46 | 47 | Big thanks to the creators and maintainers of SOFA (https://sofa.macadmins.io/), without whom this project would not be possible 48 | Huge thanks as well to the creators and maintainers of jamf-pro-sdk (https://github.com/macadmins/jamf-pro-sdk-python), also without whom this project would not be possible 49 | 50 | 51 | OVERVIEW: 52 | 53 | Thanks to GitHub Copilot for this overview and the docstrings in each function 54 | 55 | 56 | KNOWN ISSUES/DEFICIENCIES: 57 | 58 | - This script is currently hardcoded for macOS updates only. It could be modified to also support updating iOS devices. I may do this in the future, but it is not currently planned. 59 | - The sendNotifications function is planned but not currently implemented. 60 | - The --check and --retry arguments have not been tested and may not be functional. Use at your own risk. 61 | 62 | This script automates the deployment of macOS updates using Jamf Pro's Declarative Device Management (DDM) capabilities. It fetches the latest macOS version data from a SOFA feed, determines the appropriate installation deadlines based on CVE impact scores, and sends update plans to eligible devices or groups. 63 | 64 | Functions: 65 | - check_positive(value): Validates if the provided value is a positive integer. 66 | - check_version_arg(version): Validates the version argument to ensure it matches the expected format. 67 | - check_path(datafile): Checks and resolves the path for the data file. 68 | - endRun(exitCode, logLevel, message): Exits the script with a specified exit code, logging level, and final message. 69 | - loadJson(jsonPath): Loads and returns data from a JSON file. 70 | - dumpJson(jsonData, jsonPath): Dumps data into a JSON file. 71 | - sendNotifications(): Placeholder for sending notifications. 72 | - checkModelSupported(device): Checks if a device is supported for the targeted macOS version. 73 | - getCVEDetails(vulnSource, cveID, requestHeaders): Queries NVD or VulnCheck for details about a specific CVE. 74 | - parseVulns(cveList): Determines whether the update deployment should be accelerated based on CVE impact scores. 75 | - convertJamfTimestamp(timestamp): Converts a millisecond epoch timestamp to a datetime object. 76 | - calculateDeadlineString(deadlineDays): Calculates an installation deadline and returns it in a format acceptable to the Jamf API. 77 | - checkExistingDevicePlans(declarationItem, targetVersion): Checks if there are any active DDM update plans for a given device. 78 | - sendDeclaration(objectType, objectIds, installDeadlineString, osVersion): Sends DDM update plans to a device, a list of devices, or a group. 79 | - getComputerGroupData(groupID, groupName): Returns data about a computer group given its ID or name. 80 | - getVersionData(): Parses the provided SOFA feed for the latest macOS version data. 81 | - determineDeadline(cveList, exploitedCVEs): Determines the installation deadline based on runtime arguments and/or CVE data. 82 | - checkDeploymentAvailable(productVersion): Checks if the target version is available via DDM from Jamf. 83 | - checkDeviceDDMEligible(deviceRecord): Verifies if a device is eligible to receive and process a DDM update. 84 | - getPlanData(planUUID): Retrieves and returns data about an update plan and its status/history. 85 | - deduplicatePlans(planList): Filters a list of update plans to include only the most recently created plan per device. 86 | - retryPlan(plan): Retries a failed update plan. 87 | - run(): Main function to execute the script. 88 | 89 | Usage: 90 | - The script can be run with various command-line arguments to specify Jamf Pro credentials, target macOS version, update deadlines, and other options. 91 | - It supports checking and retrying existing update plans, filtering devices by group membership, and performing dry runs without executing any changes. 92 | """ 93 | 94 | import argparse 95 | import json 96 | import logging 97 | import os 98 | import re 99 | import time 100 | import sys 101 | import requests 102 | from packaging.version import Version 103 | from pathlib import Path 104 | from datetime import datetime, timedelta, timezone 105 | from tempfile import NamedTemporaryFile 106 | 107 | from jamf_pro_sdk import JamfProClient, SessionConfig 108 | from jamf_pro_sdk.models.classic import computer_groups 109 | from jamf_pro_sdk.models.pro import computers 110 | from jamf_pro_sdk.clients.pro_api.pagination import FilterField, filter_group 111 | from jamf_pro_sdk.clients.auth import ApiClientCredentialsProvider 112 | 113 | ## Version 114 | scriptVersion = "0.5.1" 115 | 116 | ## Arguments 117 | 118 | 119 | ## Validate integer inputs for deadlines 120 | def check_positive(value): 121 | """ 122 | Check if the provided value is a positive integer. 123 | 124 | Args: 125 | value (str): The value to be checked, expected to be a string representation of an integer. 126 | 127 | Returns: 128 | int: The integer value if it is positive. 129 | 130 | Raises: 131 | argparse.ArgumentTypeError: If the value is not a positive integer. 132 | """ 133 | ivalue = int(value) 134 | if ivalue <= 0: 135 | raise argparse.ArgumentTypeError("%s is an invalid positive int value" % value) 136 | return ivalue 137 | 138 | 139 | ## Validate input for target version 140 | def check_version_arg(version): 141 | """ 142 | Validates the version argument to ensure it matches the expected format. 143 | 144 | Args: 145 | version (str): The version string to validate. It can be one of the following: 146 | - "ANY" 147 | - "MAJOR" 148 | - "MINOR" 149 | - A specific macOS version (e.g., "14.6.1" or "15.0") 150 | 151 | Returns: 152 | str: The validated version string in uppercase. 153 | 154 | Raises: 155 | argparse.ArgumentTypeError: If the version string does not match the expected format. 156 | """ 157 | # Regular expression pattern to match version argument 158 | pattern = ( 159 | r"^any$" # Matches "any" 160 | r"|^major$" # Matches "major" 161 | r"|^minor$" # Matches "minor" 162 | r"|^\d+\.\d+(?:\.\d+)?$" # Matches specific macOS version (e.g., "14.6.1" or "15.0") 163 | ) 164 | 165 | if re.match(pattern, str(version), re.IGNORECASE): 166 | return version.upper() 167 | else: 168 | raise argparse.ArgumentTypeError( 169 | f'\nVersion definition must be one of the following:\n\n - ANY\n- MAJOR\n - MINOR\n - Specific macOS Version (e.g. "14.6.1" or "15.0")' 170 | ) 171 | 172 | 173 | ## Validate input for metadata file path 174 | def check_path(datafile): 175 | """ 176 | Checks and resolves the path for the data file. 177 | 178 | This function takes a path to a data file and determines if it is a directory or a file. 179 | If it is a directory, it appends "updatePlanData.json" to the directory path. 180 | If it is a file, it uses the provided file path. 181 | If neither, it defaults to "updatePlanData.json" in the current working directory. 182 | 183 | Args: 184 | datafile (str): The path to the data file or directory. 185 | 186 | Returns: 187 | Path: The resolved path to the data file. 188 | 189 | Raises: 190 | argparse.ArgumentTypeError: If the path cannot be resolved or the file cannot be created. 191 | """ 192 | dataDir = Path(datafile).expanduser().resolve() 193 | pathFail = False 194 | 195 | if dataDir.is_dir(): 196 | filePath = dataDir.joinpath("updatePlanData.json") 197 | elif Path(datafile).is_file(): 198 | filePath = Path(datafile) 199 | else: 200 | filePath = Path.cwd().joinpath("updatePlanData.json") 201 | 202 | try: 203 | filePath.touch(exist_ok=True) 204 | except: 205 | pathFail = True 206 | 207 | if not filePath.exists() or pathFail: 208 | raise argparse.ArgumentTypeError( 209 | "Unable to parse data file path. Please try again or leave blank to use the default location (current working directory)" 210 | ) 211 | else: 212 | return filePath 213 | 214 | 215 | parser = argparse.ArgumentParser( 216 | formatter_class=argparse.RawTextHelpFormatter, 217 | argument_default=argparse.SUPPRESS, 218 | ) 219 | 220 | parser.add_argument( 221 | "--jamfurl", 222 | nargs="?", 223 | help="URL for the target jamf instance -- protocol prefix not required (ex: org.jamfcloud.com)", 224 | ) 225 | 226 | parser.add_argument("--clientid", nargs="?", help="Jamf Pro API Client ID") 227 | 228 | parser.add_argument("--clientsecret", nargs="?", help="Jamf Pro API Client Secret") 229 | 230 | parser.add_argument( 231 | "--check", 232 | action="store_true", 233 | help="Read existing plan data from file and update with the latest results", 234 | ) 235 | 236 | parser.add_argument( 237 | "--retry", 238 | action="store_true", 239 | help="After checking existing plan data, retry any failed plans. Use of this option implies --check.\n\nCAUTION: Retries will re-use existing installation deadlines. This could result in devices restarting for updates with little to no warning.\nRetries for exceeded installation deadlines will receive a new deadline of 3 days.", 240 | ) 241 | 242 | parser.add_argument( 243 | "--nvdtoken", 244 | nargs="?", 245 | metavar="token", 246 | help="API key for NIST NVD. Not required, but providing one will enable faster CVE processing due to higher rate limits. NOTE: Using VulnCheck is strongly recommended over NVD due to ongoing issues with NIST update timeliness.", 247 | ) 248 | 249 | parser.add_argument( 250 | "--vulnchecktoken", 251 | nargs="?", 252 | metavar="token", 253 | help="API key for VulnCheck (https://vulncheck.com)", 254 | ) 255 | 256 | parser.add_argument( 257 | "--feedsource", 258 | default="https://sofafeed.macadmins.io/v1/macos_data_feed.json?", 259 | const="https://sofafeed.macadmins.io/v1/macos_data_feed.json?", 260 | nargs="?", 261 | metavar="URL or path", 262 | help="Full path or URL to a SOFA-generated macos_data_feed.json file. Defaults to https://sofafeed.macadmins.io/v1/macos_data_feed.json", 263 | ) 264 | 265 | parser.add_argument( 266 | "--timestamp", 267 | default="https://sofafeed.macadmins.io/v1/timestamp.json?", 268 | const="https://sofafeed.macadmins.io/v1/timestamp.json?", 269 | nargs="?", 270 | metavar="URL or path", 271 | help="Full path or URL to a SOFA-generated timestamp.json file. Defaults to https://sofafeed.macadmins.io/v1/timestamp.json", 272 | ) 273 | 274 | parser.add_argument( 275 | "--targetversion", 276 | default="ANY", 277 | const="ANY", 278 | nargs="?", 279 | type=check_version_arg, 280 | metavar="Version (string or type)", 281 | help="""Target macOS version for deployment. Can be any of the following: 282 | 283 | - Specific Version -- A specific macOS version to target for ALL eligible devices (e.g. 14.7.1) | Use --overridegroup and/or --excludegroup to target subsets of devices 284 | - "ANY" (default) -- The latest and greatest Cupertino has to offer for ALL eligible devices 285 | - "MAJOR" -- Target ONLY devices running the latest major version of macOS (e.g. updates devices on macOS 15 to the latest release of macOS 15) 286 | - "MINOR" -- Target devices running the 2 latest major versions of macOS for their respective latest releases (e.g. 14.x to latest 14 and 15.x to latest 15)""", 287 | ) 288 | 289 | parser.add_argument( 290 | "--excludegroup", 291 | nargs="+", 292 | metavar="Excluded Group Name", 293 | help="Name of a Smart/Static Computer Group containing devices to EXCLUDE from automated updates (such as conference room devices)", 294 | ) 295 | 296 | parser.add_argument( 297 | "--overridegroup", 298 | nargs="+", 299 | metavar="Override Group Name", 300 | help="Name of a Smart/Static Computer Group to target for updates (overrides default outdated group)", 301 | ) 302 | 303 | parser.add_argument( 304 | "--canarygroup", 305 | nargs="+", 306 | metavar="Canary Group Name", 307 | help='Name of a Smart/Static Computer Group containing devices to always receive a 2-day installation deadline.\n\nNOTE: Canary deployments are NOT currently compatible with --targetversion "MINOR".', 308 | ) 309 | 310 | parser.add_argument( 311 | "--canaryversion", 312 | nargs="?", 313 | metavar="macOS Version", 314 | help="macOS ProductVersion deployed to canary group. Used to ensure the same version is deployed fleetwide.", 315 | ) 316 | 317 | parser.add_argument( 318 | "--canaryok", 319 | action="store_true", 320 | help="Deploy macOS update fleetwide, assuming successful canary deployment", 321 | ) 322 | 323 | parser.add_argument( 324 | "--canarydeadline", 325 | default=2, 326 | const=2, 327 | nargs="?", 328 | type=check_positive, 329 | metavar="Days until deadline", 330 | help="Number of days before deadline for the canary group", 331 | ) 332 | 333 | parser.add_argument( 334 | "--urgentdeadline", 335 | default=7, 336 | const=7, 337 | nargs="?", 338 | type=check_positive, 339 | metavar="Days until deadline", 340 | help="Force the update to all outdated devices with the specified deadline (in days), if the aggregate CVE scores warrant accelerated deployment", 341 | ) 342 | 343 | parser.add_argument( 344 | "--deadline", 345 | default=14, 346 | const=14, 347 | nargs="?", 348 | type=check_positive, 349 | metavar="Days until deadline", 350 | help="Force the update to all outdated devices with the specified deadline (in days)", 351 | ) 352 | 353 | parser.add_argument( 354 | "--force", 355 | nargs="?", 356 | type=check_positive, 357 | metavar="Days until deadline", 358 | help="Force the update to all outdated devices with the specified deadline (in days), overriding any configured canary data", 359 | ) 360 | 361 | parser.add_argument( 362 | "--debug", 363 | action="store_true", 364 | help="Enable debug logging for this script", 365 | ) 366 | parser.add_argument( 367 | "--dryrun", 368 | action="store_true", 369 | help="Output proposed actions without executing any changes", 370 | ) 371 | 372 | parser.add_argument( 373 | "--toggleddm", 374 | action="store_true", 375 | help="Toggle DDM functionality off and on for the specified jamf tenant, clearing ALL existing DDM data", 376 | ) 377 | 378 | parser.add_argument( 379 | "--datafile", 380 | nargs="?", 381 | type=check_path, 382 | metavar="Path or filename", 383 | help="Full path or filename for storing plan data", 384 | ) 385 | 386 | parser.add_argument( 387 | "--version", 388 | action="version", 389 | version=f"{scriptVersion}", 390 | help="Show script version and exit", 391 | ) 392 | 393 | args = parser.parse_args() 394 | 395 | jamfURL = args.jamfurl if "jamfurl" in args else os.environ.get("jamfURL", None) 396 | jamfClientID = ( 397 | args.clientid if "clientid" in args else os.environ.get("jamfClientID", None) 398 | ) 399 | jamfClientSecret = ( 400 | args.clientsecret 401 | if "clientsecret" in args 402 | else os.environ.get("jamfClientSecret", None) 403 | ) 404 | 405 | checkPlans = args.check if "check" in args else None 406 | retryPlans = args.retry if "retry" in args else None 407 | 408 | nvdToken = args.nvdtoken if "nvdtoken" in args else os.environ.get("nvdToken", None) 409 | vulncheckToken = ( 410 | args.vulnchecktoken 411 | if "vulnchecktoken" in args 412 | else os.environ.get("vulncheckToken", None) 413 | ) 414 | 415 | excludedGroupName = " ".join(args.excludegroup) if "excludegroup" in args else None 416 | overrideGroupName = " ".join(args.overridegroup) if "overridegroup" in args else None 417 | 418 | targetVersionType = args.targetversion.upper() 419 | 420 | canaryGroupName = " ".join(args.canarygroup) if "canarygroup" in args else None 421 | canaryVersion = args.canaryversion.replace('"', "") if "canaryversion" in args else None 422 | canaryOK = args.canaryok if "canaryok" in args else False 423 | 424 | canaryDays = args.canarydeadline 425 | urgentDays = args.urgentdeadline 426 | standardDays = args.deadline 427 | 428 | customDeadline = True if "deadline" in args and args.deadline != 14 else False 429 | 430 | forceDays = args.force if "force" in args else None 431 | 432 | dataFilePath = ( 433 | Path(args.datafile) 434 | if "datafile" in args 435 | else Path.cwd().joinpath("updatePlanData.json") 436 | ) 437 | 438 | debug = args.debug if "debug" in args else None 439 | dryrun = args.dryrun if "dryrun" in args else False 440 | toggleDDM = args.toggleddm if "toggleddm" in args else False 441 | 442 | ############################### 443 | #### Logging configuration #### 444 | ############################### 445 | 446 | ## Local log file 447 | logFile = NamedTemporaryFile( 448 | prefix="jamf-ddm-deploy_", 449 | suffix=f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}.log", 450 | delete=False, 451 | dir=Path.cwd(), 452 | ).name 453 | 454 | ## Configure root logger 455 | logger = logging.getLogger() 456 | logger.handlers = [] 457 | 458 | ## Create handlers 459 | logToFile = logging.FileHandler(str(logFile)) 460 | jamfLogToFile = logging.FileHandler(str(logFile)) 461 | logToConsole = logging.StreamHandler(sys.stdout) 462 | jamfLogToConsole = logging.StreamHandler(sys.stdout) 463 | 464 | ## Configure logging level and format 465 | logLevel = logging.DEBUG if debug else logging.INFO 466 | logFormat = logging.Formatter( 467 | "[%(asctime)s %(filename)s->%(funcName)s():%(lineno)s]%(levelname)s: %(message)s" 468 | if debug 469 | else "%(asctime)s [%(levelname)s] %(message)s" 470 | ) 471 | 472 | ## Set root and handler logging levels 473 | logger.setLevel(logLevel) 474 | logToFile.setLevel(logLevel) 475 | logToConsole.setLevel(logLevel) 476 | 477 | ## Set log format 478 | logToFile.setFormatter(logFormat) 479 | jamfLogToFile.setFormatter(logFormat) 480 | logToConsole.setFormatter(logFormat) 481 | jamfLogToConsole.setFormatter(logFormat) 482 | 483 | ## Configure jamf SDK logging 484 | jamfLogger = logging.getLogger("jamf_pro_sdk") 485 | jamfLogLevel = logging.DEBUG if debug else logging.WARNING 486 | jamfLogger.setLevel(jamfLogLevel) 487 | 488 | ## Add handlers to jamf logger 489 | jamfLogger.addHandler(jamfLogToFile) 490 | jamfLogger.addHandler(jamfLogToConsole) 491 | 492 | ## Add handlers to root logger 493 | logger.addHandler(logToFile) 494 | logger.addHandler(logToConsole) 495 | 496 | ############################### 497 | 498 | 499 | ## Exit with a specified exit code, logging level, and final message 500 | def endRun(exitCode=None, logLevel="info", message=None): 501 | """ 502 | Terminates the program with a specified exit code and logs a message. 503 | 504 | Args: 505 | exitCode (int, optional): The exit code to terminate the program with. Defaults to None. 506 | logLevel (str, optional): The logging level for the message. Defaults to "info". 507 | message (str, optional): The message to log. Defaults to None. 508 | 509 | Raises: 510 | SystemExit: Exits the program with the specified exit code. 511 | """ 512 | 513 | logCmd = getattr(logging, logLevel, "info") 514 | logCmd = getattr(logging, logLevel, logging.info) 515 | if message: 516 | logCmd(message) 517 | sys.exit(exitCode) 518 | 519 | 520 | ## Load feed file 521 | if feedSource := args.feedsource: 522 | logging.debug(f"Attempting to fetch macOS data feed from {feedSource}...") 523 | 524 | try: 525 | if feedSource.startswith("http://") or feedSource.startswith("https://"): 526 | feedData = json.loads(requests.get(feedSource).content) 527 | 528 | elif Path(feedSource).exists(): 529 | feedData = json.loads(Path(feedSource).read_text()) 530 | except json.JSONDecodeError as e: 531 | endRun(1, "critical", f"Failed to decode JSON from {feedSource}: {e}") 532 | except: 533 | endRun(1, "critical", f"Failed to fetch feed data from {feedSource}, exiting!") 534 | 535 | else: 536 | endRun(1, "critical", "Unknown issue encountered fetching feed data, exiting...") 537 | 538 | ## Load timestamp data 539 | if timestampSource := args.timestamp: 540 | logging.debug( 541 | f"Attempting to fetch SOFA feed timestamp data from {timestampSource}..." 542 | ) 543 | 544 | try: 545 | if timestampSource.startswith("http://") or timestampSource.startswith( 546 | "https://" 547 | ): 548 | timestampData = json.loads(requests.get(timestampSource).content) 549 | 550 | elif Path(timestampSource).exists(): 551 | timestampData = json.loads(Path(timestampSource).read_text()) 552 | 553 | logging.debug("Successfully retrieved timestamp data") 554 | 555 | except: 556 | endRun( 557 | 1, 558 | "critical", 559 | f"Failed to fetch timestamp data from {timestampSource}, exiting!", 560 | ) 561 | 562 | else: 563 | endRun( 564 | 1, "critical", "Unknown issue encountered fetching timestamp data, exiting..." 565 | ) 566 | 567 | 568 | ## Load and return data from a json file 569 | def loadJson(jsonPath): 570 | """ 571 | Parameters: 572 | jsonPath (Path): The path to the JSON file to be loaded. 573 | 574 | Returns: 575 | dict: The data loaded from the JSON file. 576 | """ 577 | logging.debug(f"Loading json data from {str(jsonPath)}") 578 | try: 579 | jsonData = json.loads(jsonPath.read_text()) 580 | return jsonData 581 | except json.JSONDecodeError as e: 582 | logging.error(f"Error decoding JSON from {str(jsonPath)}: {e}") 583 | return None 584 | 585 | 586 | ## Dump data into a json file 587 | def dumpJson(jsonData, jsonPath): 588 | """ 589 | Dump data into a JSON file. 590 | 591 | Parameters: 592 | jsonData (dict): The data to be written to the JSON file. 593 | jsonPath (Path): The path to the JSON file where the data will be written. 594 | """ 595 | logging.debug(f"Dumping json data to {str(jsonPath)}") 596 | logging.debug(f"json data sent: {jsonData}") 597 | jsonPath.write_text(json.dumps(jsonData, indent=4, separators=(",", ": "))) 598 | 599 | 600 | ## TODO: Notify a Slack channel, Okta Workflow, or some other webhook when a deployment happens 601 | def sendNotifications(): 602 | pass 603 | 604 | 605 | ## Make sure a device is supported for the targeted version before sending a declaration 606 | def checkModelSupported(device): 607 | """ 608 | Checks if the given device model is supported for the target macOS version. 609 | 610 | Args: 611 | device (object): The device object containing information about the device, including its operating system and software update device ID. 612 | 613 | Returns: 614 | bool: True if the device model is supported for the target macOS version, False otherwise. 615 | 616 | Logs: 617 | A warning message if the device model is not supported for the target macOS version. 618 | """ 619 | global targetVersionSupportedDevices 620 | 621 | swuDeviceID = device.operatingSystem.softwareUpdateDeviceId 622 | 623 | if swuDeviceID not in targetVersionSupportedDevices: 624 | logging.warning( 625 | f"Device {device.id} does not support the target macOS version!" 626 | ) 627 | return swuDeviceID in targetVersionSupportedDevices 628 | 629 | 630 | ## Query NVD or VulnCheck for details about a specific CVE 631 | def getCVEDetails(vulnSource, cveID, requestHeaders): 632 | """ 633 | Retrieve details for a given CVE ID from the specified vulnerability source. 634 | 635 | Args: 636 | vulnSource (str): The source to check for CVE details. Valid values are "vulncheck" and "NVD". 637 | cveID (str): The CVE ID to retrieve details for. 638 | requestHeaders (dict): Headers to include in the request. 639 | 640 | Returns: 641 | dict: A dictionary containing CVE details including 'id', 'description', 'exploitabilityScore', and 'impactScore'. 642 | None: If no CVE ID is provided, the CVE ID is invalid, or no results are found. 643 | 644 | Raises: 645 | None 646 | 647 | Logs: 648 | Various debug, error, and warning messages to indicate the progress and any issues encountered. 649 | """ 650 | 651 | if not cveID: 652 | logging.error("No CVE ID provided, unable to get details") 653 | return None 654 | 655 | if not re.match(r"^CVE-\d{4}-\d+$", cveID, re.IGNORECASE): 656 | logging.error(f"{cveID} does not appear to be a valid CVE ID!") 657 | return None 658 | 659 | if vulnSource == "vulncheck": 660 | checkURL = "https://api.vulncheck.com/v3/index/nist-nvd2?cve=" 661 | 662 | else: 663 | vulnSource = "NVD" 664 | checkURL = "https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=" 665 | 666 | logging.debug(f"Checking {vulnSource} for details on CVE {cveID}...") 667 | cveCheckResponse = requests.get(checkURL + cveID, headers=requestHeaders) 668 | 669 | if not cveCheckResponse.ok: 670 | logging.error( 671 | f"Error occured checking for CVE details. Received return code {cveCheckResponse.status_code}" 672 | ) 673 | return None 674 | 675 | else: 676 | logging.debug("Successfully retrieved CVE data") 677 | cveResponseContent = cveCheckResponse.json() 678 | 679 | if ( 680 | cveResponseContent.get("totalResults") == 0 681 | or vulnSource == "vulncheck" 682 | and cveResponseContent.get("_meta").get("total_documents") == 0 683 | ): 684 | logging.warning(f"No results found for CVE ID {cveID}") 685 | return None 686 | 687 | if vulnSource == "vulncheck": 688 | cveData = cveResponseContent.get("data")[0] 689 | 690 | else: 691 | cveData = cveResponseContent.get("vulnerabilities")[0].get("cve") 692 | 693 | if cveMetrics := cveData.get("metrics"): 694 | cveMetricsData = cveMetrics.get(list(cveData.get("metrics").keys())[0])[0] 695 | 696 | else: 697 | cveMetricsData = {} 698 | 699 | exploitabilityScore = cveMetricsData.get("exploitabilityScore", 0) 700 | impactScore = cveMetricsData.get("impactScore", 0) 701 | 702 | cveDetails = { 703 | "id": cveID, 704 | "description": next( 705 | i.get("value") for i in cveData.get("descriptions") if i.get("lang") == "en" 706 | ), 707 | "exploitabilityScore": exploitabilityScore, 708 | "impactScore": impactScore, 709 | } 710 | 711 | logging.debug(f"CVE data: {cveDetails}") 712 | return cveDetails 713 | 714 | 715 | ## Given a list of CVEs patched in a macOS release, determine whether or not the update deployment should be accelerated based on their impact scores 716 | def parseVulns(cveList): 717 | """ 718 | Parses a list of CVEs and calculates the average exploitability and impact scores. 719 | 720 | This function retrieves CVE details from either the NVD or VulnCheck API, calculates the 721 | average exploitability and impact scores, and determines if the scores exceed predefined 722 | risk thresholds. 723 | 724 | Args: 725 | cveList (list): A list of CVE identifiers to be processed. 726 | 727 | Returns: 728 | bool: True if the average scores exceed the risk thresholds, indicating a need for a 729 | shorter installation deadline. False otherwise. 730 | """ 731 | 732 | logging.info("Calculating average CVE impact score for this release...") 733 | 734 | cveCount = len(cveList) 735 | totalExploitabilityScore = 0 736 | totalImpactScore = 0 737 | 738 | requestHeaders = {"accept": "application/json"} 739 | 740 | vulnSource = "nvd" 741 | 742 | if not any([nvdToken, vulncheckToken]): 743 | logging.debug( 744 | "No NVD or VulnCheck API tokens found, using NVD with public unauthenticated rate limits" 745 | ) 746 | standoffTime = 7 747 | 748 | elif vulncheckToken: 749 | logging.debug("Using VulnCheck API token with no rate limits") 750 | vulnSource = "vulncheck" 751 | standoffTime = 0 752 | 753 | requestHeaders.update({"Authorization": f"Bearer {vulncheckToken}"}) 754 | 755 | elif nvdToken: 756 | logging.debug("Using NVD API token for higher rate limits") 757 | standoffTime = 0.75 758 | 759 | requestHeaders.update({"apiKey": nvdToken}) 760 | 761 | for cve in cveList: 762 | exploitabilityScore = None 763 | impactScore = None 764 | if cveData := getCVEDetails(vulnSource, cve, requestHeaders): 765 | 766 | exploitabilityScore = cveData.get("exploitabilityScore", None) 767 | impactScore = cveData.get("impactScore", None) 768 | 769 | if not cveData: 770 | logging.warning( 771 | f"No CVE metrics found for {cve}, excluding from average calculation" 772 | ) 773 | cveCount -= 1 774 | 775 | else: 776 | logging.debug( 777 | f"CVE {cve}: Exploitability = {exploitabilityScore}, Impact = {impactScore}" 778 | ) 779 | totalExploitabilityScore += exploitabilityScore 780 | totalImpactScore += impactScore 781 | 782 | if standoffTime > 0: 783 | logging.debug(f"Waiting {standoffTime} seconds before next request...") 784 | time.sleep(standoffTime) 785 | if cveCount > 0: 786 | averageExploitabilityScore = round(totalExploitabilityScore / cveCount, 1) 787 | if cveCount > 0: 788 | averageExploitabilityScore = round(totalExploitabilityScore / cveCount, 1) 789 | averageImpactScore = round(totalImpactScore / cveCount, 1) 790 | else: 791 | averageExploitabilityScore = 0 792 | averageImpactScore = 0 793 | 794 | logging.debug(f"Average exploitability score: {averageExploitabilityScore}") 795 | logging.debug(f"Average impact score: {averageImpactScore}") 796 | 797 | if averageExploitabilityScore > 6: 798 | logging.info( 799 | f"Average exploitability score {averageExploitabilityScore} exceeds the threshold of 6--forcing shorter installation deadline!" 800 | ) 801 | return True 802 | elif averageImpactScore > 8: 803 | logging.info( 804 | f"Average impact score {averageImpactScore} exceeds the threshold of 8--forcing shorter installation deadline!" 805 | ) 806 | return True 807 | else: 808 | logging.info( 809 | "Average exploitability and impact scores are within normal risk ranges. No accelerated deadline required." 810 | ) 811 | return False 812 | else: 813 | logging.debug(f"Average exploitability score: {averageExploitabilityScore}") 814 | logging.debug(f"Average impact score: {averageImpactScore}") 815 | 816 | EXPLOITABILITY_THRESHOLD = 6 817 | IMPACT_THRESHOLD = 8 818 | 819 | if ( 820 | averageExploitabilityScore > EXPLOITABILITY_THRESHOLD 821 | or averageImpactScore > IMPACT_THRESHOLD 822 | ): 823 | logging.info( 824 | f"Average scores have tripped the risk threshold--forcing shorter installation deadline!" 825 | ) 826 | return True 827 | 828 | else: 829 | logging.info( 830 | "Average exploitability and impact scores are within normal risk ranges. No accelerated deadline required." 831 | ) 832 | return False 833 | 834 | 835 | ## Convert a millisecond epoch timestamp received from the jamf api to a datetime object 836 | def convertJamfTimestamp(timestamp): 837 | """ 838 | Converts a Jamf timestamp (in milliseconds) to a dictionary containing the epoch time (in seconds) 839 | and a datetime object. 840 | 841 | Args: 842 | timestamp (int): The Jamf timestamp in milliseconds. 843 | 844 | Returns: 845 | dict: A dictionary with the following keys: 846 | - "epochTime" (int): The epoch time in seconds. 847 | - "datetime" (datetime): The corresponding datetime object. 848 | 849 | Raises: 850 | ValueError: If the timestamp is missing or malformed. 851 | """ 852 | 853 | if not timestamp or not check_positive(str(timestamp)): 854 | logging.error("Timestamp missing or malformed--cannot convert!") 855 | return None 856 | 857 | timestampSeconds = round(timestamp / 1000) 858 | 859 | logging.debug( 860 | f"Converted timestamp {timestamp} to epoch {timestampSeconds}" 861 | ) 862 | 863 | return timestampSeconds 864 | 865 | 866 | ## Given an integer, calculate an installation deadline and return it in a format acceptable to the jamf API 867 | def calculateDeadlineString(deadlineDays): 868 | """ 869 | Calculate the installation deadline string based on the given number of days. 870 | 871 | This function calculates a deadline date by adding the specified number of days 872 | to the current date. If the calculated deadline falls on a weekend (Saturday or Sunday), 873 | it adjusts the deadline to the following Monday. The final deadline is formatted 874 | as an ISO 8601 string with a fixed time of 19:00:00. 875 | 876 | Args: 877 | deadlineDays (int): The number of days from the current date to set the deadline. 878 | If the value is missing or not positive, a default of 7 days is used. 879 | 880 | Returns: 881 | str: The calculated deadline date as an ISO 8601 formatted string. 882 | """ 883 | 884 | if not deadlineDays or not check_positive(str(deadlineDays)): 885 | logging.error("Deadline missing or malformed--defaulting to 7 days") 886 | deadlineDays = 7 887 | 888 | deadlineDate = datetime.now() + timedelta(days=deadlineDays) 889 | 890 | if deadlineDate.isoweekday() in {6, 7}: 891 | logging.info( 892 | "Configured deadline falls on a weekend--moving to the following Monday" 893 | ) 894 | deadlineDate += timedelta(days=8 - deadlineDate.isoweekday()) 895 | 896 | installDeadlineString = deadlineDate.strftime("%Y-%m-%dT19:00:00") 897 | 898 | return installDeadlineString 899 | 900 | 901 | ## Check if there are any active DDM update plans for a given device 902 | ## If one is found, skip sending a new declaration, because it will fail 903 | def checkExistingDevicePlans(deviceId, objectType="COMPUTER"): 904 | """ 905 | Checks for existing active (non-failed) plans for a given device. 906 | 907 | Args: 908 | deviceId (str): The ID of the device to check for existing plans. 909 | objectType (str, optional): The type of the object. Defaults to "COMPUTER". 910 | 911 | Returns: 912 | dict or None: If no existing active plan is found, returns a dictionary with the deviceId and objectType. 913 | If an existing active plan is found, returns None. 914 | 915 | Logs: 916 | Logs warnings if no deviceId is provided. 917 | Logs debug information about the attempts to check for existing plans and the results. 918 | """ 919 | 920 | global existingPlanCount 921 | global existingPlans 922 | 923 | existingActivePlan = False 924 | 925 | if not deviceId: 926 | logging.warning( 927 | "No device ID found when attempting to check for existing plans!" 928 | ) 929 | return None 930 | 931 | for i in range(1, 6): 932 | logging.debug( 933 | f"Checking for existing active (non-failed) plans for device {deviceId} (attempt {i} of 5)..." 934 | ) 935 | existingActivePlan = False 936 | existingPlansResponse = jamfClient.pro_api_request( 937 | method="GET", 938 | resource_path=f"v1/managed-software-updates/plans?filter=device.deviceId=={deviceId}", 939 | ) 940 | if existingPlansResponse.ok: 941 | devicePlanRecords = [ 942 | getPlanData(x.get("planUuid")) 943 | for x in existingPlansResponse.json().get("results") 944 | ] 945 | existingActivePlanData = next( 946 | (plan for plan in devicePlanRecords if not plan.get("planFailed")), 947 | None, 948 | ) 949 | existingActivePlan = bool(existingActivePlanData) 950 | logging.debug(f"Existing active plan found: {existingActivePlan}") 951 | break 952 | 953 | else: 954 | logging.debug( 955 | f"Received {existingPlansResponse.status_code} checking for plan events, trying again..." 956 | ) 957 | 958 | if not existingActivePlan: 959 | declarationItem = {"deviceId": deviceId, "objectType": objectType} 960 | returnData = {"isEligible": True, "item": declarationItem} 961 | logging.debug(f"No existing plan found, returning device data: {returnData}") 962 | return returnData 963 | else: 964 | existingPlanCount += 1 965 | existingActivePlan = { 966 | "planId": existingActivePlanData.get("planUuid"), 967 | "device": existingActivePlanData.get("deviceData"), 968 | } 969 | returnData = {"isEligible": False, "item": existingActivePlan} 970 | logging.debug(f"Existing plan found, returning device data: {returnData}") 971 | existingPlans.append(existingActivePlan) 972 | return returnData 973 | 974 | 975 | ## Send DDM update plans to a device, a list of devices, or a group 976 | def sendDeclaration(objectType, objectIds, installDeadlineString, osVersion): 977 | """ 978 | Sends a declaration for macOS updates to specified devices or groups. 979 | 980 | Parameters: 981 | objectType (str): The type of object to target, either "computer" or "group". 982 | objectIds (int or list): The ID(s) of the target devices or group. 983 | installDeadlineString (str): The deadline for the installation in ISO 8601 format. 984 | osVersion (str): The version of macOS to update to. 985 | 986 | Returns: 987 | list or None: A list of plans if the declaration is successful, None otherwise. 988 | """ 989 | 990 | global existingPlans 991 | 992 | if not re.match(r"^computer$|^group$", objectType, re.IGNORECASE): 993 | logging.error( 994 | f'Expected object type of "computer" or "group", received {objectType}' 995 | ) 996 | return None 997 | 998 | objectType = objectType.upper() 999 | endpoint = "v1/managed-software-updates/plans" 1000 | targetDeviceList = [] 1001 | 1002 | if objectType == "GROUP": 1003 | if not isinstance(objectIds, int): 1004 | logging.error("Group ID must be specified as a single integer!") 1005 | return None 1006 | 1007 | if groupData := fetchComputerGroupData(groupID=objectIds): 1008 | groupMembers = groupData.computers 1009 | eligibleDevices = jamfClient.concurrent_api_requests( 1010 | checkDeviceDDMEligible, [device.id for device in groupMembers] 1011 | ) 1012 | for device in eligibleDevices: 1013 | if check_positive(device.id): 1014 | deviceConfig = {"deviceId": device.id, "objectType": "COMPUTER"} 1015 | targetDeviceList.append(deviceConfig) 1016 | 1017 | else: 1018 | logging.error("Target group not found!") 1019 | return None 1020 | 1021 | else: 1022 | if ( 1023 | not isinstance(objectIds, (list, int)) 1024 | or isinstance(objectIds, str) 1025 | and objectIds.isdigit() 1026 | ): 1027 | logging.error( 1028 | "Target devices must be specified as a list or single integer!" 1029 | ) 1030 | return None 1031 | else: 1032 | if not isinstance(objectIds, list) and check_positive(objectIds): 1033 | objectIds = [objectIds] 1034 | 1035 | for device in objectIds: 1036 | if check_positive(str(device)): 1037 | deviceConfig = {"deviceId": device, "objectType": "COMPUTER"} 1038 | targetDeviceList.append(deviceConfig) 1039 | 1040 | ## Check devices in target list for existing non-failed plans in progress 1041 | targetDeviceEligibility = jamfClient.concurrent_api_requests( 1042 | checkExistingDevicePlans, 1043 | [device for device in targetDeviceList], 1044 | ) 1045 | 1046 | objectConfig = {"devices": []} 1047 | 1048 | existingPlanData = [] 1049 | 1050 | for device in targetDeviceEligibility: 1051 | if isinstance(device, dict): 1052 | if device.get("isEligible"): 1053 | objectConfig["devices"].append(device.get("item")) 1054 | else: 1055 | existingPlanData.append(device.get("item")) 1056 | else: 1057 | logging.debug(f"Something went wrong checking eligiblity for device: {device}") 1058 | 1059 | targetDeviceCount = len(objectConfig.get("devices")) 1060 | 1061 | if not targetDeviceCount: 1062 | logging.error("No eligible devices targeted for this update, exiting!") 1063 | return None 1064 | 1065 | logging.info( 1066 | f"Sending DDM update for macOS {osVersion} to object type {objectType} ({str(targetDeviceCount) + " devices" if objectType != "GROUP" else "id: " + objectIds}) with a {installDeadlineString} deadline..." 1067 | ) 1068 | logging.info(f"In-scope devices with existing plans in progress: {len(existingPlanData)}") 1069 | 1070 | delcarationConfig = { 1071 | "config": { 1072 | "updateAction": "DOWNLOAD_INSTALL_SCHEDULE", 1073 | "versionType": "SPECIFIC_VERSION", 1074 | "specificVersion": str(osVersion), 1075 | "forceInstallLocalDateTime": installDeadlineString, 1076 | } 1077 | } 1078 | 1079 | delcarationConfig.update(objectConfig) 1080 | 1081 | ## Send the plans 1082 | if not dryrun: 1083 | logging.info("Sending declaration payload...") 1084 | declarationResult = jamfClient.pro_api_request( 1085 | "post", endpoint, data=delcarationConfig 1086 | ) 1087 | 1088 | if declarationResult.status_code == 201: 1089 | logging.info("macOS update declaration was successfully sent") 1090 | planList = declarationResult.json().get("plans") 1091 | 1092 | if existingPlanData: 1093 | planList.extend(existingPlanData) 1094 | 1095 | return planList 1096 | 1097 | else: 1098 | logging.error("Something went wrong creating the update declaration plan") 1099 | return None 1100 | 1101 | else: 1102 | logging.info(f"DRY RUN: DDM payload to be sent: {delcarationConfig}") 1103 | 1104 | return None 1105 | 1106 | 1107 | ## Given a group ID or name, return data about the group 1108 | def fetchComputerGroupData(groupID=None, groupName=None): 1109 | """ 1110 | Retrieve data for a computer group by its ID or name. 1111 | 1112 | This function queries the Jamf Classic API to get information about a computer group. 1113 | The group can be identified either by its ID or its name. 1114 | 1115 | Args: 1116 | groupID (int, optional): The ID of the computer group to retrieve. 1117 | groupName (str, optional): The name of the computer group to retrieve. 1118 | 1119 | Returns: 1120 | ClassicComputerGroup or None: An instance of ClassicComputerGroup if the group is found, 1121 | otherwise None. 1122 | 1123 | Raises: 1124 | Exception: If there is an issue with the API request. 1125 | """ 1126 | 1127 | if groupID: 1128 | endpointType = "id" 1129 | query = groupID 1130 | 1131 | elif groupName: 1132 | endpointType = "name" 1133 | query = groupName 1134 | 1135 | else: 1136 | return None 1137 | 1138 | logging.info(f"Checking for computer group {endpointType} {query}...") 1139 | try: 1140 | groupDataRequest = jamfClient.classic_api_request( 1141 | "get", f"computergroups/{endpointType}/{query}" 1142 | ) 1143 | 1144 | if groupDataRequest.ok: 1145 | logging.debug("Found computer group") 1146 | groupData = computer_groups.ClassicComputerGroup( 1147 | **groupDataRequest.json()["computer_group"] 1148 | ) 1149 | logging.debug(f"Group data: {groupData}") 1150 | return groupData 1151 | except requests.exceptions.RequestException as e: 1152 | logging.warning(f"Computer group not found! Exception: {e}") 1153 | return None 1154 | 1155 | 1156 | ## Parse the provided SOFA feed for the latest macOS version data 1157 | def getVersionData(): 1158 | """ 1159 | Parses the latest SOFA feed for macOS updates and returns the update data. 1160 | 1161 | Depending on the target macOS version type, it retrieves the relevant version data 1162 | from the feed and constructs a dictionary containing the update information. 1163 | 1164 | Returns: 1165 | dict: A dictionary containing the following keys: 1166 | - targetVersion: A dictionary with the following keys: 1167 | - versionString (str): The product version string. 1168 | - releaseDate (str): The release date of the update. 1169 | - securityURL (str): The URL to the security information. 1170 | - cveList (list): A list of CVE identifiers. 1171 | - exploitedCVEs (list): A list of actively exploited CVEs. 1172 | - supportedDevices (list): A list of supported devices. 1173 | - latestPrior (optional): A dictionary with the same structure as targetVersion, 1174 | representing the latest prior version data if the target version type is "MINOR". 1175 | 1176 | Logs: 1177 | Logs information and debug messages about the parsing process and the constructed update data. 1178 | """ 1179 | 1180 | logging.info("Parsing the latest SOFA feed for macOS updates...") 1181 | logging.info(f"Target macOS version is {targetVersionType}") 1182 | 1183 | if targetVersionType == "ANY" or targetVersionType == "MAJOR": 1184 | versionData = feedData.get("OSVersions")[0].get("SecurityReleases")[0] 1185 | 1186 | elif targetVersionType == "MINOR": 1187 | versionData = feedData.get("OSVersions")[0].get("SecurityReleases")[0] 1188 | latestPriorVersionData = feedData.get("OSVersions")[1].get("SecurityReleases")[ 1189 | 0 1190 | ] 1191 | 1192 | else: 1193 | majorVersion = str(Version(targetVersionType).major) 1194 | majorVersionData = next( 1195 | v 1196 | for v in feedData.get("OSVersions") 1197 | if v.get("OSVersion").split(" ")[1] == majorVersion 1198 | ) 1199 | versionData = next( 1200 | v 1201 | for v in majorVersionData.get("SecurityReleases") 1202 | if v.get("ProductVersion") == targetVersionType 1203 | ) 1204 | 1205 | updateData = { 1206 | "targetVersion": { 1207 | "versionString": versionData.get("ProductVersion"), 1208 | "releaseDate": versionData.get("ReleaseDate"), 1209 | "securityURL": versionData.get("SecurityInfo"), 1210 | "cveList": list(versionData.get("CVEs").keys()), 1211 | "supportedDevices": versionData.get("SupportedDevices"), 1212 | "exploitedCVEs": list(versionData.get("ActivelyExploitedCVEs", [])), 1213 | } 1214 | } 1215 | 1216 | if "latestPriorVersionData" in locals(): 1217 | latestPriorData = { 1218 | "latestPrior": { 1219 | "versionString": latestPriorVersionData.get("ProductVersion"), 1220 | "releaseDate": latestPriorVersionData.get("ReleaseDate"), 1221 | "securityURL": latestPriorVersionData.get("SecurityInfo"), 1222 | "cveList": list(latestPriorVersionData.get("CVEs").keys()), 1223 | "exploitedCVEs": list( 1224 | latestPriorVersionData.get("ActivelyExploitedCVEs", []) 1225 | ), 1226 | } 1227 | } 1228 | 1229 | updateData.update(latestPriorData) 1230 | 1231 | logging.debug(updateData) 1232 | return updateData 1233 | 1234 | 1235 | ## Determine the installation deadline (in days) based on runtime arguments and/or CVE data 1236 | def determineDeadline(cveList, exploitedCVEs): 1237 | """ 1238 | Determines the installation deadline for a macOS update based on the provided CVE lists. 1239 | 1240 | Parameters: 1241 | cveList (list): A list of CVEs addressed by the update. 1242 | exploitedCVEs (list): A list of CVEs that are actively exploited. 1243 | 1244 | Returns: 1245 | int: The number of days until the installation deadline. 1246 | 1247 | The function evaluates the risk associated with the CVEs in the update and sets an appropriate deadline: 1248 | - If there are actively exploited CVEs, the deadline is accelerated. 1249 | - If the update contains high-risk CVEs, the deadline is set to urgent. 1250 | - If neither condition is met, a standard deadline is applied. 1251 | - If a custom deadline is specified, it overrides the calculated deadline. 1252 | """ 1253 | 1254 | if len(cveList) > 0 and not customDeadline: 1255 | highRiskUpdate = parseVulns(cveList) 1256 | 1257 | if len(exploitedCVEs) > 0: 1258 | logging.info( 1259 | f"Actively exploted CVEs found in this macOS update. Installation deadline will be accelerated. CVEs: {exploitedCVEs}" 1260 | ) 1261 | deadlineDays = canaryDays 1262 | 1263 | elif highRiskUpdate: 1264 | logging.info( 1265 | "No known exploits in the wild for the CVEs patched in this release, but their aggregate risk scores warrant rapid remediation. Installation deadline will be accelerated." 1266 | ) 1267 | deadlineDays = urgentDays 1268 | 1269 | else: 1270 | logging.info( 1271 | "No actively exploited CVEs listed for this release, proceeding with standard update deadline." 1272 | ) 1273 | deadlineDays = standardDays 1274 | elif customDeadline: 1275 | logging.warning( 1276 | "A custom deadline has been specified for this run. CVE checking will be skipped." 1277 | ) 1278 | deadlineDays = standardDays 1279 | 1280 | else: 1281 | logging.info( 1282 | "No CVEs listed for this release, proceeding with standard update deadline." 1283 | ) 1284 | deadlineDays = standardDays 1285 | 1286 | logging.info( 1287 | f"Calculated installation deadline for this update plan is {deadlineDays} days" 1288 | ) 1289 | return deadlineDays 1290 | 1291 | 1292 | ## Check to ensure the target version is available via DDM from jamf 1293 | def checkDeploymentAvailable(productVersion): 1294 | """ 1295 | Check if a specific macOS version is available for deployment via Jamf. 1296 | 1297 | Args: 1298 | productVersion (str): The macOS version to check for availability. 1299 | 1300 | Returns: 1301 | bool: True if the specified macOS version is available for deployment, False otherwise. 1302 | 1303 | Logs: 1304 | - An error message if the specified macOS version is not available. 1305 | - An error message if the Jamf API request fails. 1306 | - An info message if the specified macOS version is available for deployment. 1307 | """ 1308 | 1309 | availableUpdateData = jamfClient.pro_api_request( 1310 | "get", "v1/managed-software-updates/available-updates" 1311 | ) 1312 | 1313 | if availableUpdateData.ok: 1314 | availableUpdates = availableUpdateData.json().get("availableUpdates") 1315 | if availableUpdates is None: 1316 | logging.error("No available updates found in the response.") 1317 | return False 1318 | 1319 | macOSVersions = availableUpdates.get("macOS") 1320 | 1321 | if productVersion not in macOSVersions: 1322 | logging.error( 1323 | f"{productVersion} does not yet seem to be available in jamf as a managed update target. Try again later." 1324 | ) 1325 | return False 1326 | 1327 | else: 1328 | logging.error( 1329 | f"Got {availableUpdateData.status_code} back from jamf API: {availableUpdateData.content}" 1330 | ) 1331 | return False 1332 | 1333 | logging.info(f"jamf reports {productVersion} is available for DDM deployment") 1334 | return True 1335 | 1336 | 1337 | ## Before attempting to send a declaration, verify a given device is eligible to receive and process it 1338 | ## Required criteria: 1339 | ## - Device is DDM enabled 1340 | ## - Device has a bootstrap token escrowed in jamf 1341 | ## - Device is running macOS Sonoma or newer 1342 | def checkDeviceDDMEligible(deviceRecord): 1343 | """ 1344 | Checks if a device is eligible for Declarative Device Management (DDM) updates. 1345 | 1346 | Args: 1347 | deviceRecord (computers.Computer, int, str): The device record to check. It can be an instance of 1348 | `computers.Computer`, an integer representing the device ID, or a string representing the device ID. 1349 | 1350 | Returns: 1351 | computers.Computer or None: Returns the device object if it is eligible for DDM updates, otherwise returns None. 1352 | 1353 | Logs: 1354 | Logs an error if the device record is missing or malformed. 1355 | Logs debug information about the device's DDM eligibility. 1356 | Logs a warning if the device does not meet the criteria for DDM updates. 1357 | 1358 | Eligibility Criteria: 1359 | - The device must have DDM enabled. 1360 | - The device must have an escrowed bootstrap token. 1361 | - The device's operating system version must be at least macOS Sonoma (version 14). 1362 | """ 1363 | if not isinstance(deviceRecord, (computers.Computer, int, str)): 1364 | logging.error( 1365 | "Computer record missing or malformed, cannot check DDM eligibility!" 1366 | ) 1367 | return None 1368 | 1369 | if ( 1370 | isinstance(deviceRecord, int) 1371 | or isinstance(deviceRecord, str) 1372 | and str(deviceRecord).isdigit() 1373 | ): 1374 | device = computers.Computer( 1375 | **jamfClient.pro_api_request( 1376 | method="GET", 1377 | resource_path=f"v1/computers-inventory-detail/{deviceRecord}", 1378 | ).json() 1379 | ) 1380 | else: 1381 | device = deviceRecord 1382 | 1383 | if ( 1384 | isinstance(device, computers.Computer) 1385 | and device.operatingSystem.version is not None 1386 | ): 1387 | logging.debug(f"Checking DDM update eligibility for device {device.id}...") 1388 | else: 1389 | logging.error( 1390 | "Unable to retrieve device inventory, cannot check DDM eligibility!" 1391 | ) 1392 | return None 1393 | 1394 | deviceIsDDMEnabled = device.general.declarativeDeviceManagementEnabled 1395 | deviceHasBootstrapToken = device.security.bootstrapTokenEscrowedStatus == "ESCROWED" 1396 | deviceOSVersionIsAtLeastSonoma = Version(device.operatingSystem.version).major >= 14 1397 | 1398 | deviceEligibilityData = { 1399 | "deviceIsDDMEnabled": deviceIsDDMEnabled, 1400 | "deviceHasBootstrapToken": deviceHasBootstrapToken, 1401 | "deviceOSVersionIsAtLeastSonoma": deviceOSVersionIsAtLeastSonoma, 1402 | } 1403 | 1404 | if all(v for k, v in deviceEligibilityData.items()): 1405 | logging.debug("DDM updates can be deployed to this device") 1406 | return device 1407 | else: 1408 | logging.warning( 1409 | f"Some criteria were not met when checking DDM update eligibility for device ID {device.id}. " 1410 | f"Update plans will not be sent to this device.\n\nEligibility failed the following check(s):\n" 1411 | f"{', '.join([k for k in deviceEligibilityData if not deviceEligibilityData.get(k)])}\n" 1412 | ) 1413 | return None 1414 | 1415 | 1416 | ## Given an update plan UUID, retrieve and return data about the plan and its status/history 1417 | def getPlanData(planUUID): 1418 | """ 1419 | Fetches and returns detailed plan data for a given plan UUID from the Jamf Pro API. 1420 | 1421 | Args: 1422 | planUUID (str): The UUID of the plan to fetch data for. 1423 | 1424 | Returns: 1425 | dict: A dictionary containing the plan data, including: 1426 | - planUuid (str): The UUID of the plan. 1427 | - planCreated (int): The epoch time when the plan was created. 1428 | - deviceData (dict): Information about the device associated with the plan. 1429 | - installDeadline (str): The installation deadline as a string, if available. 1430 | - deadlineExceeded (bool): Whether the installation deadline has been exceeded. 1431 | - targetVersionString (str): The target OS version specified in the plan configuration. 1432 | - planCompleted (bool): Whether the device has been updated to the target OS version. 1433 | - planFailed (bool): Whether the plan has failed based on errors, deadline, or target version. 1434 | - planErrors (list): A list of error reasons associated with the plan. 1435 | 1436 | Returns None if the planUUID is not specified or if there is an error fetching the plan data. 1437 | 1438 | Raises: 1439 | Exception: If there are issues with the Jamf Pro API requests or data processing. 1440 | """ 1441 | if not planUUID: 1442 | logging.error("No plan UUID specified, cannot get plan data") 1443 | return None 1444 | logging.debug(f"Fetching plan data for {planUUID}...") 1445 | 1446 | planDeclarations = {} 1447 | planEvents = {} 1448 | ## If a plan was just created, it can take jamf some time to fully report 1449 | for i in range(1, 6): 1450 | logging.debug( 1451 | f"Fetching declarations and events for plan {planUUID} (attempt {i} of 5)..." 1452 | ) 1453 | planEventsResponse = jamfClient.pro_api_request( 1454 | method="GET", 1455 | resource_path=f"v1/managed-software-updates/plans/{planUUID}/events", 1456 | ) 1457 | 1458 | if not planEventsResponse.ok: 1459 | logging.debug( 1460 | f"Received {planEventsResponse.status_code} checking for plan events, trying again..." 1461 | ) 1462 | continue 1463 | 1464 | if not planEventsResponse.json().get("events"): 1465 | logging.debug(f"Events not ready yet, backing off and trying again...") 1466 | time.sleep(5) 1467 | else: 1468 | planDeclarationsResponse = jamfClient.pro_api_request( 1469 | method="GET", 1470 | resource_path=f"v1/managed-software-updates/plans/{planUUID}/declarations", 1471 | ) 1472 | 1473 | if not planDeclarationsResponse.ok: 1474 | logging.debug( 1475 | f"Received {planDeclarationsResponse.status_code} checking for plan declarations, trying again..." 1476 | ) 1477 | continue 1478 | 1479 | planDeclarations = planDeclarationsResponse.json() 1480 | logging.debug(f"Plan Declarations: {planDeclarations}") 1481 | planEvents = json.loads(planEventsResponse.json().get("events")).get( 1482 | "events" 1483 | ) 1484 | logging.debug(f"Plan Events: {planEvents}") 1485 | planCreatedJamf = next( 1486 | i.get("eventReceivedEpoch") 1487 | for i in planEvents 1488 | if i.get("type") == ".PlanCreatedEvent" 1489 | ) 1490 | if not planCreatedJamf: 1491 | logging.debug("Plan creation event not found, trying again...") 1492 | continue 1493 | 1494 | planCreatedEpoch = convertJamfTimestamp(planCreatedJamf) 1495 | logging.debug(f"Plan created at {planCreatedEpoch}") 1496 | break 1497 | 1498 | ## Full plan info might not be available until after declarations and events have been recorded 1499 | ## Need to allow a bit of time for jamf to catch up 1500 | currentEpochTime = int(datetime.now(timezone.utc).strftime("%s")) 1501 | planCreatedDelta = currentEpochTime - int(planCreatedEpoch) 1502 | 1503 | if planCreatedDelta <= 30: 1504 | logging.debug( 1505 | f"This plan was created {planCreatedDelta} seconds ago--allowing some extra time for jamf to catch up before fetching additional plan data..." 1506 | ) 1507 | time.sleep(5) 1508 | 1509 | planInfoResponse = jamfClient.pro_api_request( 1510 | method="GET", resource_path=f"v1/managed-software-updates/plans/{planUUID}" 1511 | ) 1512 | 1513 | if planInfoResponse.ok: 1514 | logging.debug("Successfully retrieved plan data") 1515 | planInfo = planInfoResponse.json() 1516 | logging.debug(f"Plan Information: {planInfo}") 1517 | planErrors = planInfo.get("status").get("errorReasons") 1518 | logging.debug(f"Plan Errors: {planErrors}") 1519 | 1520 | declarationConfiguration = next( 1521 | ( 1522 | i.get("payloadJson") 1523 | for i in planDeclarations.get("declarations") 1524 | if i.get("group") == "CONFIGURATION" 1525 | ), 1526 | None, 1527 | ) 1528 | 1529 | installDeadlineString = planInfo.get("forceInstallLocalDateTime", None) 1530 | if installDeadlineString: 1531 | installDeadline = datetime.strptime( 1532 | installDeadlineString, "%Y-%m-%dT%H:%M:%S" 1533 | ) 1534 | currentDateTime = datetime.now() 1535 | deadlineDelta = installDeadline - currentDateTime 1536 | deadlineExceeded = True if deadlineDelta.total_seconds() < 0 else False 1537 | else: 1538 | deadlineExceeded = None 1539 | deviceData = planInfo.get("device") 1540 | if deviceCurrentOSVersion := next( 1541 | ( 1542 | device.operatingSystem.version 1543 | for device in outdatedDevices 1544 | if device.id == deviceData.get("deviceId") 1545 | ), 1546 | None, 1547 | ): 1548 | logging.debug( 1549 | f"Retrieved device current OS version: {deviceCurrentOSVersion}" 1550 | ) 1551 | else: 1552 | deviceCurrentOSData = jamfClient.pro_api_request( 1553 | method="GET", 1554 | resource_path=deviceData.get("href").lstrip("/"), 1555 | query_params={"section": "OPERATING_SYSTEM"}, 1556 | ) 1557 | if deviceCurrentOSData.ok: 1558 | deviceCurrentOSVersion = computers.Computer( 1559 | **deviceCurrentOSData.json() 1560 | ).operatingSystem.version 1561 | logging.debug( 1562 | f"Retrieved device current OS version: {deviceCurrentOSVersion}" 1563 | ) 1564 | else: 1565 | logging.error( 1566 | f"Failed to retrieve device current OS version for device {deviceData.get("deviceId")}" 1567 | ) 1568 | deviceCurrentOSVersion = "0" 1569 | logging.debug( 1570 | f"Retrieved device current OS version: {deviceCurrentOSVersion}" 1571 | ) 1572 | if declarationConfiguration: 1573 | configurationJson = json.loads(declarationConfiguration) 1574 | targetVersionString = configurationJson.get("TargetOSVersion") 1575 | else: 1576 | targetVersionString = planInfo.get("specificVersion") 1577 | 1578 | deviceUpdated = bool( 1579 | Version(deviceCurrentOSVersion) >= Version(targetVersionString) 1580 | ) 1581 | 1582 | planData = { 1583 | "planUuid": planUUID, 1584 | "planCreated": planCreatedEpoch, 1585 | "deviceData": planInfo.get("device"), 1586 | "installDeadline": installDeadlineString, 1587 | "deadlineExceeded": deadlineExceeded, 1588 | "targetVersionString": targetVersionString, 1589 | "planCompleted": deviceUpdated, 1590 | "planFailed": ( 1591 | True 1592 | if any([len(planErrors) > 0, deadlineExceeded, not targetVersionString]) 1593 | else False 1594 | ), 1595 | "planErrors": planErrors, 1596 | } 1597 | logging.debug(f"Retrieved plan data: {planData}") 1598 | return planData 1599 | else: 1600 | logging.error("Error encountered fetching plan data") 1601 | return None 1602 | 1603 | 1604 | ## Given a list of update plans, there may be multiple plan records for a single device 1605 | ## Filter the list to include only the most recently created plan per device 1606 | def deduplicatePlans(planList): 1607 | """ 1608 | Deduplicate a list of plans by selecting the latest plan for each unique device ID. 1609 | 1610 | Args: 1611 | planList (list): A list of dictionaries, where each dictionary represents a plan. 1612 | Each dictionary must contain a 'deviceData' key with a nested 'deviceId' key, 1613 | and a 'planCreated' key indicating the creation time of the plan. 1614 | 1615 | Returns: 1616 | list: A list of dictionaries containing the latest plan for each unique device ID. 1617 | Returns None if the input is not a list. 1618 | 1619 | Logs: 1620 | Logs information about the deduplication process, including errors if the input is not a list, 1621 | and debug information about the plans being processed and selected. 1622 | """ 1623 | logging.info("Finding the latest plan for each device ID in the provided list...") 1624 | if not isinstance(planList, list): 1625 | logging.error(f"Expected a list, received {type(planList)}") 1626 | return None 1627 | filteredPlanList = [] 1628 | planDevices = set( 1629 | i["deviceData"]["deviceId"] 1630 | for i in planList 1631 | if isinstance(i, dict) and "deviceData" in i and "deviceId" in i["deviceData"] 1632 | ) 1633 | for device in planDevices: 1634 | logging.debug(f"Checking list for plans associated with device ID {device}") 1635 | latestPlanEpoch = sorted( 1636 | ( 1637 | i.get("planCreated") 1638 | for i in planList 1639 | if i.get("deviceData").get("deviceId") == device 1640 | ), 1641 | reverse=True, 1642 | )[0] 1643 | devicePlans = [ 1644 | i for i in planList if i.get("deviceData").get("deviceId") == device 1645 | ] 1646 | latestPlanData = next( 1647 | i for i in devicePlans if i.get("planCreated") == latestPlanEpoch 1648 | ) 1649 | 1650 | if latestPlanData: 1651 | logging.debug( 1652 | f"Found plan for device {device} started at {latestPlanEpoch}..." 1653 | ) 1654 | filteredPlanList.append(latestPlanData) 1655 | return filteredPlanList 1656 | 1657 | 1658 | ## Retry a failed plan 1659 | def retryPlan(plan): 1660 | """ 1661 | Retries the update plan for a given device. 1662 | 1663 | Parameters: 1664 | plan (dict): A dictionary containing the update plan data. Expected keys include: 1665 | - deviceData (dict): A dictionary containing device information. 1666 | - deviceId (str): The ID of the device. 1667 | - installDeadline (str): The current installation deadline. 1668 | - deadlineExceeded (bool): A flag indicating if the deadline has been exceeded. 1669 | - targetVersionString (str): The target OS version for the update. 1670 | - planErrors (list): A list of errors associated with the plan. 1671 | 1672 | Returns: 1673 | dict or None: A dictionary containing the new plan data if successful, or None if the plan could not be retried. 1674 | """ 1675 | 1676 | if not plan: 1677 | logging.error("No plan data received to retry!") 1678 | return None 1679 | 1680 | deviceId = plan.get("deviceId") 1681 | currentDeadline = plan.get("installDeadline") 1682 | deadlineExceeded = plan.get("deadlineExceeded") 1683 | targetVersionString = plan.get("targetVersionString") 1684 | planErrors = plan.get("planErrors") 1685 | 1686 | logging.info(f"Retrying update declaration for device {deviceId}...") 1687 | 1688 | if deadlineExceeded: 1689 | logging.debug( 1690 | "Existing deadline for this plan has elapsed. Resetting for 3 days out." 1691 | ) 1692 | newDeadline = calculateDeadlineString(3) 1693 | else: 1694 | logging.debug(f"Existing deadline of {currentDeadline} is still valid") 1695 | newDeadline = currentDeadline 1696 | 1697 | newPlan = sendDeclaration( 1698 | objectType="computer", 1699 | objectIds=deviceId, 1700 | installDeadlineString=newDeadline, 1701 | osVersion=targetVersionString, 1702 | ) 1703 | 1704 | if newPlan: 1705 | newPlanId = newPlan[0].get("planId") 1706 | newPlanData = getPlanData(newPlanId) 1707 | else: 1708 | logging.error(f"Failed to create a new plan for device {deviceId}") 1709 | return None 1710 | logging.info(f"Successfully created a new plan for device {deviceId}: {newPlanId}") 1711 | return newPlanData 1712 | 1713 | ## Get DDM feature toggle status 1714 | def getDDMStatus(): 1715 | """ 1716 | Retrieves the status of the DDM (Device Deployment Management) feature toggle from the Jamf API. 1717 | This function sends a GET request to the Jamf API to check the status of the DDM feature toggle. 1718 | If the request is successful, it returns the toggle status. If the request fails, it logs an error 1719 | message and returns None. 1720 | Returns: 1721 | bool: The status of the DDM feature toggle if the request is successful. 1722 | None: If the request fails or an error occurs. 1723 | """ 1724 | 1725 | toggleCheckResponse = jamfClient.pro_api_request( 1726 | method="GET", resource_path="v1/managed-software-updates/plans/feature-toggle" 1727 | ) 1728 | 1729 | if toggleCheckResponse.ok: 1730 | return toggleCheckResponse.json().get("toggle") 1731 | else: 1732 | logging.error( 1733 | f"Failed to retrieve DDM feature toggle status. Received {toggleCheckResponse.status_code} from jamf API: {toggleCheckResponse.content}" 1734 | ) 1735 | return None 1736 | 1737 | ## Toggle DDM feature 1738 | def toggleDDMFeature(desiredState): 1739 | """ 1740 | Toggles the DDM (Device Deployment Management) feature on or off in the Jamf API. 1741 | 1742 | Parameters: 1743 | toggleOption (bool): The desired state of the DDM feature toggle. True to enable, False to disable. 1744 | 1745 | Returns: 1746 | None 1747 | """ 1748 | 1749 | toggleDDMResponse = jamfClient.pro_api_request( 1750 | method="PUT", 1751 | resource_path="v1/managed-software-updates/plans/feature-toggle", 1752 | data={"toggle": desiredState}, 1753 | ) 1754 | if toggleDDMResponse.ok: 1755 | logging.info(f"Successfully toggled DDM updates to {desiredState}") 1756 | monitorDDMStatus("toggleOn" if desiredState else "toggleOff") 1757 | else: 1758 | logging.error( 1759 | f"Failed to toggle DDM updates to {desiredState}. Received {toggleDDMResponse.status_code} from jamf API: {toggleDDMResponse.content}" 1760 | ) 1761 | return None 1762 | 1763 | ## Monitor DDM feature toggle status 1764 | def monitorDDMStatus(toggleOption): 1765 | """ 1766 | Monitors the status of the Declarative Device Management (DDM) feature toggle. 1767 | 1768 | This function continuously checks the status of the DDM feature toggle and logs the status. 1769 | It runs indefinitely until the script is manually stopped. 1770 | 1771 | Logs: 1772 | - Logs the status of the DDM feature toggle. 1773 | - Logs an error message if the API request fails. 1774 | """ 1775 | 1776 | time.sleep(1) 1777 | 1778 | while True: 1779 | toggleCheckResponse = jamfClient.pro_api_request( 1780 | method="GET", resource_path="v1/managed-software-updates/plans/feature-toggle/status" 1781 | ).json() 1782 | toggleStateData = toggleCheckResponse.get(toggleOption) 1783 | if toggleStateData.get("state") == "RUNNING": 1784 | logging.info(f"Toggle operation in progress: {toggleStateData.get("formattedPercentComplete")}") 1785 | else: 1786 | desiredToggleState = True if toggleOption == "toggleOn" else False 1787 | if getDDMStatus() == desiredToggleState: 1788 | logging.info(f"Successfully toggled DDM updates to {desiredToggleState}") 1789 | break 1790 | elif failReason := toggleStateData.get("exitMessage"): 1791 | logging.error(f"Failed to toggle DDM updates to {desiredToggleState}: {failReason}") 1792 | endRun(1, "critical", "Failed to toggle DDM updates") 1793 | else: 1794 | logging.error("Unknown issue encountered attempting to toggle DDM functionality") 1795 | endRun(1, "critical", "Failed to toggle DDM updates") 1796 | 1797 | time.sleep(3) 1798 | 1799 | return 1800 | 1801 | ## Do the things 1802 | def run(): 1803 | """ 1804 | Executes the macOS update processor script. 1805 | 1806 | This function performs the following tasks: 1807 | 1. Validates run conditions and configures the Jamf API client. 1808 | 2. Checks if DDM updates are enabled on the Jamf tenant. 1809 | 3. Loads existing plan data if available. 1810 | 4. Validates the timestamp and feed data hashes. 1811 | 5. Parses the latest SOFA feed for macOS updates. 1812 | 6. Determines the target version and its associated data. 1813 | 7. Generates a run summary with configured options. 1814 | 8. Checks and retries existing plans if specified. 1815 | 9. Retrieves the list of devices not running the target macOS version. 1816 | 10. Filters devices based on group memberships and eligibility. 1817 | 11. Splits target devices between N and N-1 major versions if applicable. 1818 | 12. Sends update plans to the Jamf API. 1819 | 13. Logs the run results and updates the metadata file. 1820 | 1821 | Global Variables: 1822 | - outdatedDevices: List of devices that are outdated. 1823 | - canaryGroupName: Name of the canary group. 1824 | - existingPlanCount: Count of existing plans. 1825 | - existingPlans: List of existing plans. 1826 | - targetVersionSupportedDevices: List of devices supported by the target version. 1827 | 1828 | Raises: 1829 | - Ends the run with an appropriate message and status code if any critical condition fails. 1830 | """ 1831 | 1832 | ## Declare global args 1833 | global outdatedDevices 1834 | global canaryGroupName 1835 | global existingPlanCount 1836 | global existingPlans 1837 | global targetVersionSupportedDevices 1838 | 1839 | logging.debug("Validating run conditions...") 1840 | 1841 | ## Configure the jamf API client 1842 | if not all([jamfURL, jamfClientID, jamfClientSecret]): 1843 | endRun(1, "critical", "Jamf Pro URL and/or credentials not found!") 1844 | 1845 | else: 1846 | global jamfClient 1847 | 1848 | jamfClient = JamfProClient( 1849 | server=jamfURL, 1850 | credentials=ApiClientCredentialsProvider(jamfClientID, jamfClientSecret), 1851 | session_config=SessionConfig( 1852 | **{"timeout": 30, "max_retries": 5, "max_concurrency": 25} 1853 | ), 1854 | ) 1855 | 1856 | ## Make sure DDM updates are enabled on the jamf tenant 1857 | logging.debug("Checking to ensure DDM updates are enabled...") 1858 | if getDDMStatus(): 1859 | logging.debug("DDM updates are enabled") 1860 | else: 1861 | logging.warning("DDM updates do not appear to be enabled on this jamf tenant. Attempting to enable...") 1862 | toggleDDMFeature(True) 1863 | 1864 | ## Toggle DDM functionality on the jamf tenant if specified 1865 | if toggleDDM: 1866 | logging.info("Toggling DDM updates on the jamf tenant...") 1867 | toggleDDMFeature(False) 1868 | toggleDDMFeature(True) 1869 | 1870 | if dataFilePath.exists(): 1871 | logging.info(f"Found metadata file at {dataFilePath}, processing...") 1872 | currentPlanData = loadJson(dataFilePath) 1873 | 1874 | else: 1875 | currentPlanData = None 1876 | 1877 | lastRunTime = timestampData.get("macOS").get("LastCheck").replace("Z", "") 1878 | currentEpochTime = int(datetime.now(timezone.utc).strftime("%s")) 1879 | lastRunTimeEpoch = int(datetime.fromisoformat(lastRunTime).strftime("%s")) 1880 | runDelta = currentEpochTime - lastRunTimeEpoch 1881 | 1882 | if runDelta >= 604800: 1883 | logging.warning( 1884 | f"The current run data is over a week old. Ensure the data feed builder is running properly." 1885 | ) 1886 | 1887 | timestampHash = timestampData.get("macOS").get("UpdateHash") 1888 | dataHash = feedData.get("UpdateHash") 1889 | 1890 | ## Fail out if the hashes don't match 1891 | if timestampHash != dataHash: 1892 | endRun( 1893 | 1, 1894 | logLevel="critical", 1895 | message=f"Feed data hash {dataHash} does not match hash found in timestamp data ({timestampHash})! Verify your SOFA feed.", 1896 | ) 1897 | 1898 | logging.info("Parsing the latest SOFA feed for macOS updates...") 1899 | 1900 | outdatedDevices = [] 1901 | updateData = getVersionData() 1902 | createdPlans = [] 1903 | existingPlans = [] 1904 | isCanary = False 1905 | 1906 | targetVersionData = updateData.get("targetVersion") 1907 | targetVersionString = targetVersionData.get("versionString") 1908 | releaseDate = targetVersionData.get("releaseDate") 1909 | cveList = targetVersionData.get("cveList") 1910 | exploitedCVEs = targetVersionData.get("exploitedCVEs") 1911 | targetVersionSupportedDevices = targetVersionData.get("supportedDevices") 1912 | 1913 | if targetVersionType == "MINOR": 1914 | logging.info( 1915 | f"Initializing update plans for the latest minor macOS version. N and N-1 major releases will be targeted." 1916 | ) 1917 | latestPriorData = updateData.get("latestPrior") 1918 | latestPriorVersionString = latestPriorData.get("versionString") 1919 | latestPriorReleaseDate = latestPriorData.get("releaseDate") 1920 | latestPriorCVEList = latestPriorData.get("cveList") 1921 | latestPriorExploitedCVEs = latestPriorData.get("exploitedCVEs") 1922 | 1923 | latestPriorDeadlineDays = determineDeadline( 1924 | latestPriorCVEList, latestPriorExploitedCVEs 1925 | ) 1926 | 1927 | logging.info(f"Latest N-1 release is {latestPriorVersionString}") 1928 | logging.info( 1929 | f"Installation of this update for targeted devices will be required in {latestPriorDeadlineDays} days" 1930 | ) 1931 | 1932 | else: 1933 | logging.info( 1934 | f"Initializing update plans for macOS version {targetVersionString}" 1935 | ) 1936 | 1937 | deadlineDays = determineDeadline(cveList, exploitedCVEs) 1938 | logging.info( 1939 | f"Installation of this update for targeted devices will be required in {deadlineDays} days" 1940 | ) 1941 | 1942 | if forceDays: 1943 | logging.info(f"Forced update detected--setting deadline of {forceDays} days") 1944 | deadlineDays = forceDays 1945 | canaryGroupName = None 1946 | 1947 | ## Begin generating run summary 1948 | createdPlanCount = 0 1949 | failedPlanCount = 0 1950 | existingPlanCount = 0 1951 | runSummary = f""" 1952 | ####################################### 1953 | #### DDM Update Deployment Summary #### 1954 | ####################################### 1955 | 1956 | ## Run Started: {datetime.strftime(datetime.now(), "%Y-%m-%d %H:%M:%S")} 1957 | 1958 | ## Configured Options: 1959 | - jamf URL: {jamfURL} 1960 | - Target Version Type: {targetVersionType} 1961 | - Latest macOS Version: {targetVersionString} 1962 | """ 1963 | 1964 | if targetVersionType == "MINOR": 1965 | runSummary = ( 1966 | runSummary + f"- Latest N-1 macOS Version: {latestPriorVersionString}\n" 1967 | ) 1968 | 1969 | if excludedGroupName: 1970 | runSummary = runSummary + f"- Excluded Group: {excludedGroupName}\n" 1971 | 1972 | if overrideGroupName: 1973 | runSummary = runSummary + f"- Override Group: {overrideGroupName}\n" 1974 | 1975 | if canaryGroupName: 1976 | runSummary = ( 1977 | runSummary 1978 | + f""" 1979 | - Canary Group: {canaryGroupName} 1980 | - Canary Deployment OK: {canaryOK} 1981 | - Canary Version Deployed: {canaryVersion} 1982 | 1983 | """ 1984 | ) 1985 | 1986 | runSummary = runSummary + f"- Dry Run: {dryrun}\n\n" 1987 | 1988 | if checkPlans or retryPlans: 1989 | runSummary = ( 1990 | runSummary 1991 | + f""" 1992 | - Check Existing Plans: {checkPlans} 1993 | - Retry Failed Plans: {retryPlans} 1994 | 1995 | """ 1996 | ) 1997 | checkedPlanCount = 0 1998 | retriedPlanCount = 0 1999 | ## load current plans and check them. if retry, recalc deadline if needed and re-send failures 2000 | if not currentPlanData: 2001 | endRun( 2002 | 1, 2003 | logLevel="error", 2004 | message="Check or retry options were specified but no existing plan data was found. Please verify your data file path and try again.", 2005 | ) 2006 | 2007 | latestCanaryPlans, latestStandardPlans, priorCanaryPlans, priorStandardPlans = ( 2008 | [], 2009 | [], 2010 | [], 2011 | [], 2012 | ) 2013 | 2014 | if currentPlans := currentPlanData.get("latest"): 2015 | if latestCanaryPlanData := currentPlans.get("canary"): 2016 | latestCanaryPlans = deduplicatePlans(latestCanaryPlanData.get("plans")) 2017 | checkedPlanCount += len(latestCanaryPlans) 2018 | planCheckResults = jamfClient.concurrent_api_requests( 2019 | getPlanData, [p.get("planUuid") for p in latestCanaryPlans] 2020 | ) 2021 | for plan in planCheckResults: 2022 | ## fix these pops in check/retry logic 2023 | ## maybe not required 2024 | latestCanaryPlans.pop( 2025 | latestCanaryPlans.index( 2026 | next( 2027 | i 2028 | for i in latestCanaryPlans 2029 | if i.get("planUuid") == plan.get("planUuid") 2030 | ) 2031 | ) 2032 | ) 2033 | 2034 | if plan.get("planFailed") and retryPlans: 2035 | plan = retryPlan(plan) 2036 | retriedPlanCount += 1 2037 | 2038 | latestCanaryPlans.append(plan) 2039 | 2040 | currentPlanData.update( 2041 | {"latest": {"canary": {"plans": latestCanaryPlans}}} 2042 | ) 2043 | 2044 | if latestStandardPlanData := currentPlans.get("standard"): 2045 | latestStandardPlans = deduplicatePlans( 2046 | latestStandardPlanData.get("plans") 2047 | ) 2048 | checkedPlanCount += len(latestStandardPlans) 2049 | planCheckResults = jamfClient.concurrent_api_requests( 2050 | getPlanData, [p.get("planUuid") for p in latestStandardPlans] 2051 | ) 2052 | for plan in planCheckResults: 2053 | latestStandardPlans.pop( 2054 | latestStandardPlans.index( 2055 | next( 2056 | i 2057 | for i in latestStandardPlans 2058 | if i.get("planUuid") == plan.get("planUuid") 2059 | ) 2060 | ) 2061 | ) 2062 | latestStandardPlans.append(plan) 2063 | 2064 | if plan.get("planFailed") and retryPlans: 2065 | plan = retryPlan(plan) 2066 | retriedPlanCount += 1 2067 | 2068 | latestStandardPlans.append(plan) 2069 | 2070 | currentPlanData.update( 2071 | {"latest": {"standard": {"plans": latestStandardPlans}}} 2072 | ) 2073 | 2074 | if priorMajorPlans := currentPlanData.get("latestPrior"): 2075 | if priorCanaryPlanData := priorMajorPlans.get("canary"): 2076 | priorCanaryPlans = deduplicatePlans(priorCanaryPlanData.get("plans")) 2077 | checkedPlanCount += len(priorCanaryPlans) 2078 | planCheckResults = jamfClient.concurrent_api_requests( 2079 | getPlanData, [p.get("planUuid") for p in priorCanaryPlans] 2080 | ) 2081 | for plan in planCheckResults: 2082 | priorCanaryPlans.pop( 2083 | priorCanaryPlans.index( 2084 | next( 2085 | i 2086 | for i in priorCanaryPlans 2087 | if i.get("planUuid") == plan.get("planUuid") 2088 | ) 2089 | ) 2090 | ) 2091 | priorCanaryPlans.append(plan) 2092 | 2093 | if plan.get("planFailed") and retryPlans: 2094 | plan = retryPlan(plan) 2095 | retriedPlanCount += 1 2096 | 2097 | priorCanaryPlans.append(plan) 2098 | 2099 | currentPlanData.update( 2100 | {"latestPrior": {"canary": {"plans": priorCanaryPlans}}} 2101 | ) 2102 | 2103 | if priorStandardPlanData := priorMajorPlans.get("standard"): 2104 | priorStandardPlans = deduplicatePlans( 2105 | priorStandardPlanData.get("plans") 2106 | ) 2107 | checkedPlanCount += len(priorStandardPlans) 2108 | planCheckResults = jamfClient.concurrent_api_requests( 2109 | getPlanData, [p.get("planUuid") for p in priorStandardPlans] 2110 | ) 2111 | for plan in planCheckResults: 2112 | priorStandardPlans.pop( 2113 | priorStandardPlans.index( 2114 | next( 2115 | i 2116 | for i in priorStandardPlans 2117 | if i.get("planUuid") == plan.get("planUuid") 2118 | ) 2119 | ) 2120 | ) 2121 | priorStandardPlans.append(plan) 2122 | 2123 | if plan.get("planFailed") and retryPlans: 2124 | plan = retryPlan(plan) 2125 | retriedPlanCount += 1 2126 | 2127 | priorStandardPlans.append(plan) 2128 | 2129 | currentPlanData.update( 2130 | {"latestPrior": {"standard": {"plans": priorStandardPlans}}} 2131 | ) 2132 | 2133 | if not any( 2134 | [ 2135 | latestCanaryPlans, 2136 | latestStandardPlans, 2137 | priorCanaryPlans, 2138 | priorStandardPlans, 2139 | ] 2140 | ): 2141 | endRun(0, message="No existing plans found to check, exiting...") 2142 | 2143 | dumpJson(currentPlanData, dataFilePath) 2144 | runSummary = ( 2145 | runSummary 2146 | + f""" 2147 | - Checked Plans: {checkedPlanCount} 2148 | - Retried Plans: {retriedPlanCount} 2149 | 2150 | ## Run Finished: {datetime.strftime(datetime.now(), "%Y-%m-%d %H:%M:%S")} 2151 | """ 2152 | ) 2153 | logging.info("Plan checks completed and metadata updated, exiting.") 2154 | endRun(0, message=runSummary) 2155 | 2156 | logging.info( 2157 | f"Retrieving list of devices not running at least macOS {targetVersionString}..." 2158 | ) 2159 | if targetVersionType == "MINOR": 2160 | outdatedDevicesFilter = filter_group( 2161 | FilterField("general.remoteManagement.managed").eq(True) 2162 | ) & filter_group( 2163 | filter_group( 2164 | FilterField("operatingSystem.version").eq( 2165 | str(Version(targetVersionString).major) + ".*" 2166 | ) 2167 | & FilterField("operatingSystem.version").lt(targetVersionString) 2168 | ) 2169 | | filter_group( 2170 | FilterField("operatingSystem.version").eq( 2171 | str(Version(latestPriorVersionString).major) + ".*" 2172 | ) 2173 | & FilterField("operatingSystem.version").lt(latestPriorVersionString) 2174 | ) 2175 | ) 2176 | elif targetVersionType == "MAJOR": 2177 | outdatedDevicesFilter = filter_group( 2178 | FilterField("general.remoteManagement.managed").eq(True) 2179 | ) & filter_group( 2180 | FilterField("operatingSystem.version").eq( 2181 | str(Version(targetVersionString).major) + ".*" 2182 | ) 2183 | & FilterField("operatingSystem.version").lt(targetVersionString) 2184 | ) 2185 | else: 2186 | outdatedDevicesFilter = FilterField("general.remoteManagement.managed").eq( 2187 | True 2188 | ) & FilterField("operatingSystem.version").lt(targetVersionString) 2189 | 2190 | outdatedDevices = jamfClient.pro_api.get_computer_inventory_v1( 2191 | sections=[ 2192 | "GENERAL", 2193 | "HARDWARE", 2194 | "OPERATING_SYSTEM", 2195 | "SECURITY", 2196 | "GROUP_MEMBERSHIPS", 2197 | ], 2198 | filter_expression=outdatedDevicesFilter, 2199 | return_generator=False, 2200 | ) 2201 | 2202 | ## if any group operations are being done, filter results accordingly 2203 | if groupData := fetchComputerGroupData(groupName=excludedGroupName): 2204 | logging.info(f"Excluding devices in group {excludedGroupName}") 2205 | groupMembers = [str(member.id) for member in groupData.computers] 2206 | popIndices = [] 2207 | for device in outdatedDevices: 2208 | if str(device.id) in groupMembers: 2209 | if deviceIndex := next( 2210 | ( 2211 | index 2212 | for (index, d) in enumerate(outdatedDevices) 2213 | if d.id == device.id 2214 | ), 2215 | None, 2216 | ): 2217 | logging.debug( 2218 | f"Removing device {device.id} from list at index {deviceIndex}..." 2219 | ) 2220 | popIndices.append(deviceIndex) 2221 | for i in sorted(popIndices, reverse=True): 2222 | outdatedDevices.pop(i) 2223 | logging.info(f"Filtered to {len(outdatedDevices)} outdated devices") 2224 | 2225 | if groupData := fetchComputerGroupData(groupName=overrideGroupName): 2226 | logging.info(f"Filtering to only devices in group {overrideGroupName}") 2227 | groupMembers = [str(member.id) for member in groupData.computers] 2228 | popIndices = [] 2229 | for device in outdatedDevices: 2230 | if str(device.id) not in groupMembers: 2231 | if deviceIndex := next( 2232 | ( 2233 | index 2234 | for (index, d) in enumerate(outdatedDevices) 2235 | if d.id == device.id 2236 | ), 2237 | None, 2238 | ): 2239 | logging.debug( 2240 | f"Removing device {device.id} from list at index {deviceIndex}..." 2241 | ) 2242 | popIndices.append(deviceIndex) 2243 | for i in sorted(popIndices, reverse=True): 2244 | outdatedDevices.pop(i) 2245 | logging.info(f"Filtered to {len(outdatedDevices)} outdated devices") 2246 | isCanary = False 2247 | 2248 | ## deal with canary group filtering 2249 | if groupData := fetchComputerGroupData(groupName=canaryGroupName): 2250 | 2251 | if targetVersionType == "MINOR": 2252 | logging.error( 2253 | 'Canary deployments are currently not compatible with the "MINOR" target version type.' 2254 | ) 2255 | endRun(1) 2256 | 2257 | if not canaryOK: 2258 | 2259 | logging.info(f"Filtering to only devices in canary group {canaryGroupName}") 2260 | 2261 | for device in outdatedDevices: 2262 | groupMembers = [str(member.id) for member in groupData.computers] 2263 | popIndices = [] 2264 | for device in outdatedDevices: 2265 | if str(device.id) not in groupMembers: 2266 | if deviceIndex := next( 2267 | ( 2268 | index 2269 | for (index, d) in enumerate(outdatedDevices) 2270 | if d.id == device.id 2271 | ), 2272 | None, 2273 | ): 2274 | logging.debug( 2275 | f"Removing device {device.id} from list at index {deviceIndex}..." 2276 | ) 2277 | popIndices.append(deviceIndex) 2278 | for i in sorted(popIndices, reverse=True): 2279 | outdatedDevices.pop(i) 2280 | logging.info(f"Filtered to {len(outdatedDevices)} outdated devices") 2281 | deadlineDays = canaryDays 2282 | logging.info(f"Setting deadline to {deadlineDays} days for canary deployment") 2283 | isCanary = True 2284 | 2285 | else: 2286 | 2287 | if canaryVersion == targetVersionString: 2288 | 2289 | logging.info( 2290 | f"Canary version {canaryVersion} matches current, proceeding with wide deployment" 2291 | ) 2292 | for device in outdatedDevices: 2293 | groupMembers = [str(member.id) for member in groupData.computers] 2294 | popIndices = [] 2295 | for device in outdatedDevices: 2296 | if str(device.id) in groupMembers: 2297 | if deviceIndex := next( 2298 | ( 2299 | index 2300 | for (index, d) in enumerate(outdatedDevices) 2301 | if d.id == device.id 2302 | ), 2303 | None, 2304 | ): 2305 | logging.debug( 2306 | f"Removing device {device.id} from list at index {deviceIndex}..." 2307 | ) 2308 | popIndices.append(deviceIndex) 2309 | for i in sorted(popIndices, reverse=True): 2310 | outdatedDevices.pop(i) 2311 | logging.info(f"Filtered to {len(outdatedDevices)} outdated devices") 2312 | isCanary = False 2313 | 2314 | else: 2315 | 2316 | logging.warning( 2317 | f"Canary version {canaryVersion} does not match current release {targetVersionString}! Ending run out of an abundance of caution. Please verify your canary deployment and try again." 2318 | ) 2319 | endRun(1) 2320 | 2321 | ## filter out ineligible devices 2322 | logging.info("Verifying update deployment eligibility for in-scope devices...") 2323 | ineligibleDevices = [] 2324 | popIndices = [] 2325 | for device in outdatedDevices: 2326 | if not all([checkDeviceDDMEligible(device), checkModelSupported(device)]): 2327 | ineligibleDevices.append(device.id) 2328 | if deviceIndex := next( 2329 | ( 2330 | index 2331 | for (index, d) in enumerate(outdatedDevices) 2332 | if d.id == device.id 2333 | ), 2334 | None, 2335 | ): 2336 | logging.debug( 2337 | f"Removing device {device.id} from list at index {deviceIndex}..." 2338 | ) 2339 | popIndices.append(deviceIndex) 2340 | 2341 | if len(ineligibleDevices) > 0: 2342 | logging.warning( 2343 | f"Found {len(ineligibleDevices)} devices ineligible for DDM update deployment. See run summary for details.\nDeployment will proceed to remaining eligible devices.\n" 2344 | ) 2345 | for i in sorted(popIndices, reverse=True): 2346 | outdatedDevices.pop(i) 2347 | else: 2348 | logging.info("All in-scope devices eligible for DDM update deployment!") 2349 | 2350 | ## if MINOR deployment, split out results between N major version and N-1 2351 | if targetVersionType == "MINOR": 2352 | currentMajorVersion = Version(targetVersionString).major 2353 | priorMajorVersion = Version(latestPriorVersionString).major 2354 | logging.info( 2355 | f"Splitting target device list to N ({currentMajorVersion}) and N-1 ({priorMajorVersion}) major versions..." 2356 | ) 2357 | 2358 | currentMajorTargets = [] 2359 | priorMajorTargets = [] 2360 | 2361 | for device in outdatedDevices: 2362 | if Version(device.operatingSystem.version).major == currentMajorVersion: 2363 | logging.debug( 2364 | f"Adding device {device.id} to current major version targets..." 2365 | ) 2366 | currentMajorTargets.append(device) 2367 | 2368 | elif Version(device.operatingSystem.version).major == priorMajorVersion: 2369 | if Version(device.operatingSystem.version) < Version( 2370 | latestPriorVersionString 2371 | ): 2372 | logging.debug( 2373 | f"Adding device {device.id} to prior major version targets..." 2374 | ) 2375 | priorMajorTargets.append(device) 2376 | else: 2377 | logging.debug( 2378 | f"Device {device.id} appears to already be up to date with the latest version of N-1." 2379 | ) 2380 | 2381 | else: 2382 | logging.warning( 2383 | f"Unable to determine major version target for device {device.id}! Adding to prior major version targets..." 2384 | ) 2385 | priorMajorTargets.append(device) 2386 | 2387 | logging.info( 2388 | f"Found {len(currentMajorTargets)} eligible devices requiring an update for macOS {currentMajorVersion}" 2389 | ) 2390 | logging.info( 2391 | f"Found {len(priorMajorTargets)} eligible devices requiring an update for macOS {priorMajorVersion}" 2392 | ) 2393 | 2394 | else: 2395 | currentMajorTargets = [ 2396 | device 2397 | for device in outdatedDevices 2398 | if Version(device.operatingSystem.version) < Version(targetVersionString) 2399 | ] 2400 | logging.info(f"Found {len(currentMajorTargets)} outdated devices") 2401 | logging.info( 2402 | f"Sending the latest and greatest from Cuptertino to all in-scope devices..." 2403 | ) 2404 | 2405 | ## send plans 2406 | planData = dict() 2407 | if targetVersionType == "MINOR" and len(priorMajorTargets) > 0: 2408 | priorCreatedPlans = [] 2409 | installDeadlineString = calculateDeadlineString(latestPriorDeadlineDays) 2410 | if planSuccessData := sendDeclaration( 2411 | objectType="computer", 2412 | objectIds=[computer.id for computer in priorMajorTargets], 2413 | installDeadlineString=installDeadlineString, 2414 | osVersion=latestPriorVersionString, 2415 | ): 2416 | for plan in jamfClient.concurrent_api_requests( 2417 | getPlanData, [p.get("planId") for p in planSuccessData] 2418 | ): 2419 | if plan.get("planFailed"): 2420 | if "EXISTING_PLAN_FOR_DEVICE_IN_PROGRESS" in plan.get("planErrors"): 2421 | existingPlanCount += 1 2422 | else: 2423 | failedPlanCount += 1 2424 | else: 2425 | priorCreatedPlans.append(plan) 2426 | createdPlanCount += 1 2427 | 2428 | priorPlanData = { 2429 | "prior": { 2430 | "canary" if isCanary else "standard": { 2431 | "planCreated": datetime.strftime( 2432 | datetime.now(), "%Y-%m-%dT%H:%M:%S" 2433 | ), 2434 | "targetVersion": latestPriorVersionString, 2435 | "installationDeadline": installDeadlineString, 2436 | "installationDeadlineEpoch": int( 2437 | datetime.timestamp( 2438 | datetime.strptime( 2439 | installDeadlineString, "%Y-%m-%dT%H:%M:%S" 2440 | ) 2441 | ) 2442 | ), 2443 | "plans": priorCreatedPlans, 2444 | } 2445 | } 2446 | } 2447 | 2448 | planData.update(priorPlanData) 2449 | 2450 | elif not dryrun: 2451 | logging.error("Something went wrong sending this plan") 2452 | 2453 | createdPlans = [] 2454 | installDeadlineString = calculateDeadlineString(deadlineDays) 2455 | if planSuccessData := sendDeclaration( 2456 | objectType="computer", 2457 | objectIds=[computer.id for computer in currentMajorTargets], 2458 | installDeadlineString=installDeadlineString, 2459 | osVersion=targetVersionString, 2460 | ): 2461 | for plan in jamfClient.concurrent_api_requests( 2462 | getPlanData, [p.get("planId") for p in planSuccessData] 2463 | ): 2464 | if isinstance(plan, dict): 2465 | if plan.get("planFailed"): 2466 | if "EXISTING_PLAN_FOR_DEVICE_IN_PROGRESS" in plan.get("planErrors"): 2467 | existingPlanCount += 1 2468 | else: 2469 | failedPlanCount += 1 2470 | else: 2471 | createdPlans.append(plan) 2472 | createdPlanCount += 1 2473 | else: 2474 | logging.error("Failed to retrieve plan data for this plan, marking as failed") 2475 | failedPlanCount += 1 2476 | 2477 | latestPlanData = { 2478 | "latest": { 2479 | "canary" if isCanary else "standard": { 2480 | "planCreated": datetime.strftime( 2481 | datetime.now(), "%Y-%m-%dT%H:%M:%S" 2482 | ), 2483 | "targetVersion": targetVersionString, 2484 | "installationDeadline": installDeadlineString, 2485 | "installationDeadlineEpoch": int( 2486 | datetime.timestamp( 2487 | datetime.strptime( 2488 | installDeadlineString, "%Y-%m-%dT%H:%M:%S" 2489 | ) 2490 | ) 2491 | ), 2492 | "plans": createdPlans, 2493 | } 2494 | } 2495 | } 2496 | 2497 | planData.update(latestPlanData) 2498 | 2499 | elif not dryrun: 2500 | logging.error("Something went wrong sending this plan") 2501 | 2502 | runSummary = ( 2503 | runSummary 2504 | + f""" 2505 | ## Run Results: 2506 | - Total outdated devices in scope: {len(outdatedDevices)} 2507 | - Devices ineligible for update deployment: {len(ineligibleDevices)} 2508 | {"- Ineligible device IDs: " + ", ".join(ineligibleDevices) if ineligibleDevices else ""} 2509 | - Devices with Existing Active Plans: {existingPlanCount} 2510 | - Update plans created: {createdPlanCount if not dryrun else "0 (dry run)"} 2511 | - Update plans failed: {failedPlanCount if not dryrun else "0 (dry run)"} 2512 | 2513 | ## Run Finished: {datetime.strftime(datetime.now(), "%Y-%m-%d %H:%M:%S")} 2514 | 2515 | ## Full log available at {logFile} 2516 | """ 2517 | ) 2518 | 2519 | if not dryrun and planData: 2520 | dumpJson(planData, dataFilePath) 2521 | 2522 | else: 2523 | logging.debug(planData) 2524 | 2525 | endRun(0, message=runSummary) 2526 | 2527 | 2528 | if __name__ == "__main__": 2529 | run() 2530 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | jamf-pro-sdk 2 | packaging 3 | pathlib 4 | requests --------------------------------------------------------------------------------