Switch AutoPkg recipes to HTTPS
Jan 1, 2021
2 minute read

AutoPkg recipes automate and codify the often tedious tasks involved in packaging and distributing Mac software. Central to AutoPkg’s greatness are the many built-in security measures that verify you’re getting the software you intend — including code signature verification, embedded trust information in overrides, and the autopkg audit command.

AutoPkg recipe authors should also follow another important security practice: use HTTPS URLs instead of HTTP whenever possible. Whether downloading actual software or downloading metadata about the software, using an HTTPS URL helps prevent person-in-the-middle attacks and keep your organization’s software pipeline secure.

In particular, the arguments and input variables used by the URLDownloader, URLTextSearcher, and SparkleUpdateInfoProvider processors should use HTTPS if the option is available, and recipe authors should perform periodic checks to detect when software developers (or their CDNs) begin offering HTTPS downloads.

The security benefits aren’t just theoretical; a few years ago, security researchers demonstrated an attack targeting Mac apps using insecure Sparkle feeds. Ben Toms wrote a good article detailing the Mac admin community’s response to the vulnerability.

HTTPS Spotter

Checking for the existence of HTTPS URLs can be tedious if you manage more than a handful of AutoPkg recipes, so I’ve written a Python tool called HTTPS Spotter that will automate the process for you. The source code is on GitHub and embedded below.

Requirements

To use the script, you’ll need Git and AutoPkg installed.

Steps

  1. Clone the script to your Mac (substitute the path to your source, if not ~/Developer).

     git clone https://gist.github.com/66d1c8772baf5f731bb8ddf263f33401.git ~/Developer/https_spotter
    
  2. Run the script with --help to see usage information.

     /usr/local/autopkg/python ~/Developer/https_spotter/https_spotter.py --help
    
  3. Now run the script again, pointing it to your repository of AutoPkg recipes:

     /usr/local/autopkg/python ~/Developer/https_spotter/https_spotter.py ~/Developer/your-autopkg-recipes
    

    You’ll see output that might look like this:

     ../homebysix-recipes/NeoFinder/NeoFinder.download.recipe
      Replace: http://www.cdfinder.de/en/downloads.html
         With: https://www.cdfinder.de/en/downloads.html
     ../homebysix-recipes/FontFinagler/FontFinagler.download.recipe
      Replace: http://www.markdouma.com/fontfinagler/version.xml
         With: https://www.markdouma.com/fontfinagler/version.xml
    
     2 suggested changes. To apply, run again with --auto.
    
  4. Run the script again with the --auto flag in order to automatically apply the changes, or apply the changes manually in your preferred text editor.

  5. Test the modified recipes prior to committing/pushing the changes to your public repo on GitHub.

    tip

    Here’s a one-liner that will run recently-modified recipes in “check only” mode:

    find * -iname "*.recipe" -mtime -1 -exec autopkg run -vvcq "{}" '+'
    

Source code

The script is below. Suggestions or improvements are welcome!

#!/usr/local/autopkg/python
# encoding: utf-8
# HTTPS Spotter
# Copyright 2016-2024 Elliot Jordan
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""https_spotter.py.
Scans for HTTP URLs in a specified repository of AutoPkg recipes and suggests
HTTPS alternatives wherever possible.
"""
import argparse
import os
import plistlib
import subprocess
import yaml
# Path to curl.
CURL_PATH = "/usr/bin/curl"
def build_argument_parser():
"""Build and return the argument parser."""
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument(
"repo_path", nargs="?", help="Path to search for AutoPkg recipes."
)
parser.add_argument(
"--auto",
action="store_true",
default=False,
help="Automatically apply suggested changes to recipes. Only recommended "
"for repos you manage or are submitting a pull request to. (Applying "
"changes to repos added using `autopkg repo-add` may result in failure "
"to update the repo in the future.)",
)
parser.add_argument(
"-v",
"--verbose",
action="count",
default=0,
help="Print additional output useful for "
"troubleshooting. (Can be specified multiple times.)",
)
return parser
def check_url(url, verbose):
"""Checks a given HTTPS URL to see whether an HTTPS equivalent is
available."""
https_url = url.replace("http:", "https:")
try:
cmd = [
CURL_PATH,
"--head",
"--silent",
"--location",
"--max-time",
"5",
"--max-redirs",
"10",
"--url",
https_url,
]
if verbose > 1:
print(" Curl command: %s" % " ".join(cmd))
proc = subprocess.run(cmd, check=False, capture_output=True)
http_response = [x for x in proc.stdout.splitlines() if x.startswith(b"HTTP/")]
http_status = int(http_response[0].split()[1])
if verbose > 2:
print(" Exit code: %s" % proc.returncode)
print(" HTTP status: %s" % http_status)
if proc.returncode == 0:
# Ignoring HTTP status because 404 with SSL is better than 404 without.
return https_url
except Exception as err:
if verbose > 3:
print(" %s" % err)
return False
def process_recipe(recipe_path, args):
"""Processes one recipe."""
try:
# Read recipe.
if not os.path.isfile(recipe_path):
return
if recipe_path.endswith(".recipe.yaml"):
with open(recipe_path, "r") as openfile:
recipe = yaml.safe_load(openfile)
else:
with open(recipe_path, "rb") as openfile:
recipe = plistlib.load(openfile)
if "Process" in recipe:
processors = [x.get("Processor") for x in recipe["Process"]]
if "DeprecationWarning" in processors:
if args.verbose > 0:
print("Skipping: %s (contains DeprecationWarning)" % (recipe_path))
return
if args.verbose > 0:
print("Processing: %s" % (recipe_path))
urls_to_check = []
# Gather HTTP URLs from Input.
for key in recipe.get("Input", {}):
if isinstance(recipe["Input"][key], str) and recipe["Input"][
key
].startswith("http:"):
urls_to_check.append(recipe["Input"][key])
# Gather HTTP URLs from Processors.
for proc in recipe.get("Process", []):
if not proc.get("Arguments"):
continue
for _, value in proc.get("Arguments", {}).items():
if not isinstance(value, str):
# Only looking at string arguments, not diving
# deeper into lists/dicts.
continue
if value.startswith("http:"):
urls_to_check.append(value)
# Check HTTP URLs for HTTPS equivalents.
for http_url in urls_to_check:
https_url = check_url(http_url, args.verbose)
if not https_url:
continue
if args.auto:
# Read/write as text instead of using plistlib in order
# to prevent unrelated changes (e.g. whitespace) in diff.
with open(recipe_path, "r") as openfile:
recipe_contents = openfile.read()
with open(recipe_path, "w") as openfile:
openfile.write(recipe_contents.replace(http_url, https_url))
print(recipe_path)
print(" Replaced: %s" % (http_url))
print(" With: %s" % (https_url))
else:
print(recipe_path)
print(" Replace: %s" % (http_url))
print(" With: %s" % (https_url))
return True
except Exception as err:
print("[ERROR] %s (%s)" % (recipe_path, err))
raise err
def main():
"""Scans all recipes in the specified folder for HTTP URLs."""
# Parse command line arguments.
argparser = build_argument_parser()
args = argparser.parse_args()
if args.repo_path:
path = args.repo_path
else:
path = "~/Library/AutoPkg/Recipes"
# Process all recipes looking for HTTP URLs.
suggestion_count = 0
for dirpath, dirnames, filenames in os.walk(os.path.expanduser(path)):
for dirname in dirnames:
dirnames = [x for x in dirnames if not x.startswith(".")]
filenames = [
x for x in filenames if x.endswith(".recipe") or x.endswith(".recipe.yaml")
]
for filename in filenames:
if process_recipe(os.path.join(dirpath, filename), args):
suggestion_count += 1
if not args.auto and suggestion_count > 0:
changes = "change" if suggestion_count == 1 else "changes"
print(
"\n%d suggested %s. To apply, run again with --auto."
% (suggestion_count, changes)
)
if __name__ == "__main__":
main()