From 9ec9c0cd052823b407cb9d217f824cda8e019f61 Mon Sep 17 00:00:00 2001 From: jc0b Date: Fri, 10 May 2024 23:00:30 +0200 Subject: [PATCH] Added tests, renamed file --- .github/workflows/test.yml | 16 ++++ README.md | 8 +- main.py => nudge-auto-updater.py | 117 +++++++++++++++++++--------- nudge-config.json | 10 +-- tests/test-latest/configuration.yml | 30 +++++++ tests/test-latest/feed.json | 85 ++++++++++++++++++++ tests/test-latest/nudge-config.json | 16 ++++ 7 files changed, 237 insertions(+), 45 deletions(-) create mode 100644 .github/workflows/test.yml rename main.py => nudge-auto-updater.py (82%) create mode 100644 tests/test-latest/configuration.yml create mode 100644 tests/test-latest/feed.json create mode 100644 tests/test-latest/nudge-config.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..11cff0f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,16 @@ +name: Run Tests +on: push +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + submodules: recursive + - name: Generate Nudge file + run: | + python3 -m pip install -r requirements.txt + ./nudge-auto-updater.py -s file://${GITHUB_WORKSPACE}/tests/test-latest/feed.json -c ${GITHUB_WORKSPACE}/tests/test-latest/configuration.yml -n ${GITHUB_WORKSPACE}/tests/test-latest/nudge-config.json + - name: Run tests + run: | + [[ $(jq -r '.OSVersions.[0].SecurityReleases.[0].ProductVersion' ${GITHUB_WORKSPACE}/tests/test-latest/feed.json) == $(jq -r '.osVersionRequirements.[0].requiredMinimumOSVersion' ${GITHUB_WORKSPACE}/tests/test-latest/nudge-config.json) ]] diff --git a/README.md b/README.md index 4785ed3..b789822 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # nudge-auto-updater -A tool to demo how you can update [Nudge](https://github.com/macadmins/Nudge) JSON configuration definitions automatically. +`nudge-auto-updater` is a tool that leverages [SOFA](https://sofa.macadmins.io) in combination with [VulnCheck](https://docs.vulncheck.com/) to detect new macOS updates, triage the severity of the CVEs fixed, and update your JSON [Nudge](https://github.com/macadmins/Nudge) configuration appropriately. -Leverages [SOFA](https://sofa.macadmins.io) for the macOS update feed, and [NIST's National Vulnerability Database REST API](https://nvd.nist.gov/developers/vulnerabilities) for grabbing info about CVEs. +A VulnCheck API key is currently required to use this script - without it, CVE lookups can't be performed. ## Configuration You can configure this program by putting a `configuration.yml` file in the same directory as the script. -Under the ket `targets`, this `configuration.yml` file should contain a list of `osVersionRequirements`, the keys of which are documented below: +Under the key `targets`, this `configuration.yml` file should contain a list of `osVersionRequirements`, the keys of which are documented below: | Key | Type | Description | |-----------------------|--------|----------------------| | `target` | string | Specifies the `targetedOSVersionsRule` in Nudge. | @@ -13,6 +13,6 @@ Under the ket `targets`, this `configuration.yml` file should contain a list of To do: describe rest of keys -If the `configuration.yml` file is missing this program will only update the Nudge configuration `osVersionRequirements` for the default `targetedOSVersionsRule` to the latest version of macOS. +If the `configuration.yml` file is missing this script will only update the Nudge configuration `osVersionRequirements` for the default `targetedOSVersionsRule` to the latest version of macOS. The specified configuration will only update existing `osVersionRequirements` - it will not create new ones. An example file is included in this project. \ No newline at end of file diff --git a/main.py b/nudge-auto-updater.py similarity index 82% rename from main.py rename to nudge-auto-updater.py index 674dbf2..aa77928 100755 --- a/main.py +++ b/nudge-auto-updater.py @@ -2,13 +2,14 @@ import datetime import json import logging +import optparse import os import re import sys import urllib.error import urllib.request -CONFIG_FILE_NAME = "configuration.yml" +DEFAULT_CONFIG_FILE_NAME = "configuration.yml" DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" HEADERS = {'accept': 'application/json', 'User-Agent': 'nudge-auto-updater/1.0'} DEFAULT_CONFIG = { @@ -17,18 +18,9 @@ "default_deadline_days" : 14, "urgent_deadline_days" : 7 } - -using_default_config = False - -try: - import yaml -except ModuleNotFoundError as e: - if os.path.exists(CONFIG_FILE_NAME): - logging.error(f"Can't read configuration file: {e}") - sys.exit(1) - else: - using_default_config = True - logging.warning("PyYAML library could not be loaded, but no configuration file is present.\nWill continue with default settings.") +DEFAULT_NUDGE_FILENAME = "nudge-config.json" +DEFAULT_SOFA_FEED = "https://sofa.macadmins.io/v1/macos_data_feed.json" +VERSION="0.0.1" # ---------------------------------------- # Version @@ -113,14 +105,14 @@ def __lt__(self, other): def get_nudge_config() -> dict: logging.info("Loading Nudge config...") try: - f = open("nudge-config.json") + f = open(nudge_filename) try: data = json.load(f) except Exception as e: - logging.error("Unable to load nudge-config.json") + logging.error(f"Unable to load {nudge_filename}") sys.exit(1) except Exception as e: - logging.error("Unable to open nudge-config.json") + logging.error(f"Unable to open {nudge_filename}") sys.exit(1) logging.info("Successfully loaded Nudge config!") @@ -137,10 +129,10 @@ def read_nudge_requirements(d:dict): def write_nudge_config(d:dict): try: - with open('nudge-config.json', 'w') as f: + with open(nudge_filename, 'w') as f: json.dump(d, f, indent=4) except Exception as e: - logging.error("Unable to write to nudge-config.json") + logging.error(f"Unable to write to {nudge_filename}") sys.exit(1) def update_nudge_file_dict(d:dict, target, version, url, days): @@ -155,15 +147,13 @@ def update_nudge_file_dict(d:dict, target, version, url, days): d["osVersionRequirements"][i]["requiredInstallationDate"] = datestr d["osVersionRequirements"][i]["requiredMinimumOSVersion"] = str(version) return d - logging.error(f"Unable to find target {target} in nudge-config.json.") + logging.error(f"Unable to find target {target} in {nudge_filename}.") sys.exit(1) def adjust_url(url, change): - print(type(url)) - print(url) i = url.rfind("/") + 1 url = url[:i] - url += "/" + change + url += change return url def adjust_date_str(datestr, days): @@ -177,7 +167,7 @@ def adjust_date_str(datestr, days): # macOS # ---------------------------------------- def get_macos_data(): - req = urllib.request.Request(url="https://sofa.macadmins.io/v1/macos_data_feed.json", headers=HEADERS, method="GET") + req = urllib.request.Request(url=sofa_url, headers=HEADERS, method="GET") try: response = urllib.request.urlopen(req) except urllib.error.HTTPError as e: @@ -218,6 +208,8 @@ def process_url(s:str): return parts[-1] def get_CVE_scores(s:str, b:bool): + vulncheck_headers = HEADERS + vulncheck_headers["Authorization"] = f"Bearer {api_key}" req = urllib.request.Request(url=f"https://api.vulncheck.com/v3/index/nist-nvd2?cve={s}", headers=HEADERS, method="GET") try: response = urllib.request.urlopen(req) @@ -247,21 +239,21 @@ def read_CVE_scores(d:dict, b:bool): # ---------------------------------------- def get_config() -> dict: global using_default_config - if not os.path.exists(CONFIG_FILE_NAME): + if not os.path.exists(config_file): using_default_config = True logging.warning("No configuration file is present. Will continue with default settings.") if using_default_config: return DEFAULT_CONFIG - with open(CONFIG_FILE_NAME, "r") as config_yaml: - logging.info(f"Loading {CONFIG_FILE_NAME} ...") + with open(config_file, "r") as config_yaml: + logging.info(f"Loading {config_file} ...") try: result = yaml.safe_load(config_yaml) - logging.info(f"Successfully loaded {CONFIG_FILE_NAME}!") + logging.info(f"Successfully loaded {config_file}!") if result == None or len(result) < 1: return DEFAULT_CONFIG return result except yaml.YAMLError as e: - logging.error(f"Unable to load {CONFIG_FILE_NAME}") + logging.error(f"Unable to load {config_file}") sys.exit(1) return result @@ -433,7 +425,7 @@ def main(): if nudge_requirements[target["target"]]["version"] < latest_macos_releases[0]: is_uptodate = False new_macos_release = latest_macos_releases[0] - logging.info(f"Nudge configuration for target {target['target']} needs to be updated from {nudge_requirements[target['target']]['version']} to {new_macos_release})") + logging.info(f"Nudge configuration for target \"{target['target']}\" needs to be updated from {nudge_requirements[target['target']]['version']} to {new_macos_release})") else: is_uptodate = True else: @@ -446,7 +438,7 @@ def main(): new_macos_release = macos_release break if is_uptodate: - logging.info(f"Nudge configuration for target {target['target']} is already up to date.") + logging.info(f"Nudge configuration for target \"{target['target']}\" is already up to date.") else: # nudge is not up to date! Is the new update urgent? # get security metrics @@ -468,6 +460,10 @@ def main(): write_nudge_config(nudge_file_dict) logging.info("Nudge configuration updated.") +def config_help(msg): + + sys.exit(1) + def setup_logging(): logger = logging.getLogger(__name__) logging.basicConfig( @@ -478,12 +474,61 @@ def setup_logging(): if __name__ == '__main__': setup_logging() - try: - global api_key - api_key = os.environ["VULNCHECK_API_KEY"] - HEADERS["Authorization"] = f"Bearer {api_key}" + usage = """usage: %prog [options]\nScript to update a Nudge JSON configuration file.""" + parser = optparse.OptionParser(usage=usage, version=VERSION) + parser.add_option('--sofa-url', '-s', dest='sofa_url', + help="Custom SOFA feed URL. Should include the path to macos_data_feed.json.\nDefaults to https://sofa.macadmins.io/v1/macos_data_feed.json") + parser.add_option('--nudge-file', '-n', dest='nudge_file', + help="The Nudge JSON config file to update.\nDefaults to nudge-config.json") + parser.add_option('--api-key', '-a', dest='api_key', + help="A VulnCheck API key for getting CVE data. It is required to either set this argument, or the VULNCHECK_API_KEY environment variable.") + parser.add_option('--config-file', '-c', dest='config_file', + help="The path to a yaml-formatted file containing the configuration for nudge-auto-updater") + + options, arguments = parser.parse_args() + global sofa_url + global nudge_filename + global api_key + global config_file + + if options.sofa_url: + sofa_url = options.sofa_url + logging.info(f"Using {sofa_url} as a custom SOFA feed...") + else: + sofa_url = DEFAULT_SOFA_FEED + + if not options.nudge_file: + nudge_filename = DEFAULT_NUDGE_FILENAME + else: + nudge_filename = options.nudge_file + + if os.environ.get("VULNCHECK_API_KEY"): + api_key = os.environ.get("VULNCHECK_API_KEY") + logging.info("Using the provided VulnCheck API key...") + elif options.api_key: + api_key = options.api_key logging.info("Using the provided VulnCheck API key...") - except KeyError as e: - logging.error(f"The {e} environment variable is not set. A VulnCheck API key is required to use this script.\nSee https://docs.vulncheck.com/getting-started/api-tokens for more.") + else: + logging.error(f"A VulnCheck API key is required to use this script. Please set it using either the VULNCHECK_API_KEY environment variable, or the --api-key argument.\n\tSee https://docs.vulncheck.com/getting-started/api-tokens for more.") sys.exit(1) + + if options.config_file: + config_file = options.config_file + logging.info(f"Using {config_file} for deferral configuration...") + else: + config_file = DEFAULT_CONFIG_FILE_NAME + + global using_default_config + using_default_config = False + + try: + import yaml + except ModuleNotFoundError as e: + if os.path.exists(config_file): + logging.error(f"Can't read configuration file: {e}") + sys.exit(1) + else: + using_default_config = True + logging.warning("PyYAML library could not be loaded, but no configuration file is present.\nWill continue with default settings.") + main() diff --git a/nudge-config.json b/nudge-config.json index 7e714ec..8567ecb 100644 --- a/nudge-config.json +++ b/nudge-config.json @@ -15,23 +15,23 @@ }, "osVersionRequirements": [ { - "aboutUpdateURL_disabled": "https://support.apple.com/en-us//HT214096", + "aboutUpdateURL_disabled": "https://support.apple.com/en-us/HT214096", "aboutUpdateURLs": [ { "_language": "en", - "aboutUpdateURL": "https://support.apple.com/en-us//HT214096" + "aboutUpdateURL": "https://support.apple.com/en-us/HT214096" } ], - "requiredInstallationDate": "2024-04-22T14:00:00Z", + "requiredInstallationDate": "2024-07-15T14:00:00Z", "requiredMinimumOSVersion": "14.4.1", "targetedOSVersionsRule": "default" }, { - "aboutUpdateURL_disabled": "https://support.apple.com/en-us//HT214095", + "aboutUpdateURL_disabled": "https://support.apple.com/en-us/HT214095", "aboutUpdateURLs": [ { "_language": "en", - "aboutUpdateURL": "https://support.apple.com/en-us//HT214095" + "aboutUpdateURL": "https://support.apple.com/en-us/HT214095" } ], "requiredInstallationDate": "2024-04-23T14:00:00Z", diff --git a/tests/test-latest/configuration.yml b/tests/test-latest/configuration.yml new file mode 100644 index 0000000..8e1d0f7 --- /dev/null +++ b/tests/test-latest/configuration.yml @@ -0,0 +1,30 @@ +--- + targets: + - target : "default" + update_to : "latest" + cve_urgency_conditions: + max_baseScore : 10 + average_baseScore : 8 + max_exploitabilityScore : 10 + average_exploitabilityScore : 8 + max_impactScore : 10 + average_impactScore : 8 + number_CVEs : 10 + number_actively_exploited_CVEs : 5 + fraction_actively_exploited_CVEs : 0.7 + formulas: + - comparison : "average" + formula : "baseScore * exploitabilityScore * impactScore" + threshhold : 500 + - comparison : "max" + formula : "baseScore * exploitabilityScore * impactScore * is_actively_exploited" + threshhold : 200 + - comparison : "sum" + formula : "baseScore * impactScore * is_actively_exploited" + threshhold : 300 + - comparison : "n_above" + formula : "baseScore * impactScore * is_actively_exploited" + n : 2 + threshhold : 300 + default_deadline_days : 14 + urgent_deadline_days : 7 \ No newline at end of file diff --git a/tests/test-latest/feed.json b/tests/test-latest/feed.json new file mode 100644 index 0000000..05f8044 --- /dev/null +++ b/tests/test-latest/feed.json @@ -0,0 +1,85 @@ +{ + "OSVersions": [ + { + "OSVersion": "Sonoma 14", + "Latest": { + "ProductVersion": "14.4.1", + "Build": "23E224", + "ReleaseDate": "2024-03-25T00:00:00Z", + "ExpirationDate": "2024-08-07T00:00:00Z", + "SupportedDevices": [ + "J132AP", + "J137AP", + "J140AAP", + "J140KAP", + "J152FAP", + "J160AP", + "J174AP", + "J180dAP", + "J185AP", + "J185FAP", + "J213AP", + "J214KAP", + "J215AP", + "J223AP", + "J230KAP", + "J274AP", + "J293AP", + "J313AP", + "J314cAP", + "J314sAP", + "J316cAP", + "J316sAP", + "J375cAP", + "J375dAP", + "J413AP", + "J414cAP", + "J414sAP", + "J415AP", + "J416cAP", + "J416sAP", + "J433AP", + "J434AP", + "J456AP", + "J457AP", + "J473AP", + "J474sAP", + "J475cAP", + "J475dAP", + "J493AP", + "J504AP", + "J514cAP", + "J514mAP", + "J514sAP", + "J516cAP", + "J516mAP", + "J516sAP", + "J613AP", + "J615AP", + "J680AP", + "J780AP", + "Mac-1E7E29AD0135F9BC", + "Mac-63001698E7A34814", + "Mac-937A206F2EE63C01", + "Mac-AA95B1DDAB278B95", + "VMA2MACOSAP", + "VMM-x86_64" + ] + }, + "SecurityReleases": [ + { + "UpdateName": "macOS Sonoma 14.4.1", + "ProductVersion": "14.4.1", + "ReleaseDate": "2024-03-25T00:00:00Z", + "SecurityInfo": "https://support.apple.com/kb/HT214096", + "CVEs": { + "CVE-2024-1580": false + }, + "ActivelyExploitedCVEs": [], + "UniqueCVEsCount": 1, + "DaysSincePreviousRelease": 18 + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/test-latest/nudge-config.json b/tests/test-latest/nudge-config.json new file mode 100644 index 0000000..bfb3723 --- /dev/null +++ b/tests/test-latest/nudge-config.json @@ -0,0 +1,16 @@ +{ + "osVersionRequirements": [ + { + "aboutUpdateURL_disabled": "https://support.apple.com/en-us/HT214096", + "aboutUpdateURLs": [ + { + "_language": "en", + "aboutUpdateURL": "https://support.apple.com/en-us/HT214096" + } + ], + "requiredInstallationDate": "2024-07-15T14:00:00Z", + "requiredMinimumOSVersion": "14.4.1", + "targetedOSVersionsRule": "default" + } + ] +} \ No newline at end of file