diff --git a/.github/workflows/build_python_3.11.yml b/.github/workflows/build_python_3.11.yml index 1d774a6..e9294c4 100644 --- a/.github/workflows/build_python_3.11.yml +++ b/.github/workflows/build_python_3.11.yml @@ -51,6 +51,7 @@ jobs: echo "PYTHON_BUILD_VERSION=$(/bin/cat ./build_info.txt)" >> $GITHUB_ENV - name: Generate changelog + if: github.event_name == 'workflow_dispatch' id: changelog uses: metcalfc/changelog-generator@0440d0932f9a0dd1cc9ecd8412830761351323bd # v4.7.0 with: @@ -58,6 +59,7 @@ jobs: reverse: 'true' - name: Create Release + if: github.event_name == 'workflow_dispatch' id: create_release uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: diff --git a/.github/workflows/build_python_3.12.yml b/.github/workflows/build_python_3.12.yml index e02ca9a..e640734 100644 --- a/.github/workflows/build_python_3.12.yml +++ b/.github/workflows/build_python_3.12.yml @@ -51,6 +51,7 @@ jobs: echo "PYTHON_BUILD_VERSION=$(/bin/cat ./build_info.txt)" >> $GITHUB_ENV - name: Generate changelog + if: github.event_name == 'workflow_dispatch' id: changelog uses: metcalfc/changelog-generator@0440d0932f9a0dd1cc9ecd8412830761351323bd # v4.7.0 with: @@ -58,6 +59,7 @@ jobs: reverse: 'true' - name: Create Release + if: github.event_name == 'workflow_dispatch' id: create_release uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: diff --git a/.github/workflows/build_python_3.13.yml b/.github/workflows/build_python_3.13.yml index e31a46f..7e65a67 100644 --- a/.github/workflows/build_python_3.13.yml +++ b/.github/workflows/build_python_3.13.yml @@ -5,7 +5,7 @@ env: DEV_INSTALLER_ID: "Developer ID Installer: Mac Admins Open Source (T4SK8ZXCXG)" DEV_APPLICATION_ID: "Developer ID Application: Mac Admins Open Source (T4SK8ZXCXG)" NOTARY_APP_PASSWORD: ${{ secrets.NOTARY_APP_PASSWORD_MAOS }} - PYTHON_VERSION: "3.13.13" + PYTHON_VERSION: "3.13.17" PYTHON_MAJOR_VERSION: "3.13" on: @@ -51,6 +51,7 @@ jobs: echo "PYTHON_BUILD_VERSION=$(/bin/cat ./build_info.txt)" >> $GITHUB_ENV - name: Generate changelog + if: github.event_name == 'workflow_dispatch' id: changelog uses: metcalfc/changelog-generator@0440d0932f9a0dd1cc9ecd8412830761351323bd # v4.7.0 with: @@ -58,6 +59,7 @@ jobs: reverse: 'true' - name: Create Release + if: github.event_name == 'workflow_dispatch' id: create_release uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: @@ -71,7 +73,7 @@ jobs: Python ${{env.PYTHON_VERSION}} Framework ## Changes - - Upgraded Python to 3.13.13 + - Upgraded Python to 3.13.17 **Note: Some of these updates may have breaking changes. Always test your code before deploying to production!** Please see the `requirements_recommended.txt` for the current libraries being used. diff --git a/.github/workflows/build_python_3.14.yml b/.github/workflows/build_python_3.14.yml index 88fef2f..5a54d99 100644 --- a/.github/workflows/build_python_3.14.yml +++ b/.github/workflows/build_python_3.14.yml @@ -5,7 +5,7 @@ env: DEV_INSTALLER_ID: "Developer ID Installer: Mac Admins Open Source (T4SK8ZXCXG)" DEV_APPLICATION_ID: "Developer ID Application: Mac Admins Open Source (T4SK8ZXCXG)" NOTARY_APP_PASSWORD: ${{ secrets.NOTARY_APP_PASSWORD_MAOS }} - PYTHON_VERSION: "3.14.5" + PYTHON_VERSION: "3.14.9" PYTHON_MAJOR_VERSION: "3.14" on: @@ -51,6 +51,7 @@ jobs: echo "PYTHON_BUILD_VERSION=$(/bin/cat ./build_info.txt)" >> $GITHUB_ENV - name: Generate changelog + if: github.event_name == 'workflow_dispatch' id: changelog uses: metcalfc/changelog-generator@0440d0932f9a0dd1cc9ecd8412830761351323bd # v4.7.0 with: @@ -58,6 +59,7 @@ jobs: reverse: 'true' - name: Create Release + if: github.event_name == 'workflow_dispatch' id: create_release uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: @@ -71,7 +73,7 @@ jobs: Python ${{env.PYTHON_VERSION}} Framework ## Changes - - Upgraded Python to 3.14.5 + - Upgraded Python to 3.14.9 **Note: Some of these updates may have breaking changes. Always test your code before deploying to production!** Please see the `requirements_recommended.txt` for the current libraries being used. diff --git a/README.md b/README.md index fd9c582..baeed5c 100644 --- a/README.md +++ b/README.md @@ -47,11 +47,29 @@ Not supported. Build an unsigned framework on Apple Silicon with: ``` -./build_python_framework_pkgs.zsh --python-version 3.13.13 +./build_python_framework_pkgs.zsh --python-version 3.13.17 ``` Pass `--installer-id`, `--application-id`, and `--notary-password` to produce a signed and notarized `.pkg`. +The build bootstraps pip 26.2.2 from a SHA-256-verified wheel before accessing +PyPI, updates the pip bundled with `ensurepip`, and installs certifi 2026.10.19 +and virtualenv 21.14.13. These updates address the +[eMudhra/emSign certificate security incident](https://discuss.python.org/t/109576) +for the framework and newly created Python 3.11–3.14 virtual environments. +Python 3.13 and 3.14 builds use the updated 3.13.17 and 3.14.9 installers; +3.11 and 3.12 retain their last upstream macOS installers with patched `ensurepip`. + +Before packaging, the build verifies package dependencies, CA stores, offline +`venv` and `virtualenv` creation, and HTTPS access through the standard library +and Requests. Pull requests build and notarize packages; publishing a release +requires a manual workflow run. + +Existing virtual environments have their own copies of pip and certifi and must +be updated separately using the advisory's instructions. Python 3.9 is outside +this framework's supported versions; virtualenv's compatibility seed for that +version does not include the fixed pip. + # Updating packages Do this in a clean virtual environment. After every Python package install, run `pip freeze | xargs pip uninstall -y` to reset the environment. diff --git a/build_python_framework_pkgs.zsh b/build_python_framework_pkgs.zsh index 39146de..e8867c5 100755 --- a/build_python_framework_pkgs.zsh +++ b/build_python_framework_pkgs.zsh @@ -11,6 +11,10 @@ set -eu # --- Pinned upstream commits --- RP_SHA="8ee72fe3a5dbef733365370ebf44f25022b895ef" # gregneagle/relocatable-python MP_SHA="bbd07730d1b93ed3828246575ef5676bba74b5d1" # munki/munki-pkg +# Python security advisory: https://discuss.python.org/t/109576 +PIP_VERSION="26.2.2" +PIP_SHA256="fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70" +PIP_URL="https://files.pythonhosted.org/packages/3f/06/3e4420302363fbb71be3c7f35ce042f54bfca330e3e66b3a2e3c4cca579f/pip-${PIP_VERSION}-py3-none-any.whl" # --- Paths and constants --- TYPE="recommended" @@ -131,10 +135,35 @@ build_framework() { --baseurl "${PYTHON_BASEURL}" \ --python-version "${PYTHON_VERSION}" \ --os-version 11 \ - --upgrade-pip \ - --pip-requirements "${TOOLSDIR}/requirements_${TYPE}.txt" \ + --without-pip \ --destination "${FRAMEWORKDIR}" + # The upstream helper installs wheel online with the installer-bundled pip + # before upgrading pip. Bootstrap a verified, fixed wheel offline instead. + local python_bin="${FRAMEWORKDIR}/Python.framework/Versions/${PYTHON_BIN_VERSION}/bin/python${PYTHON_BIN_VERSION}" + local pip_dir pip_wheel + pip_dir="$(/usr/bin/mktemp -d /tmp/macadmins-python-pip.XXXXXX)" + pip_wheel="${pip_dir}/pip-${PIP_VERSION}-py3-none-any.whl" + /usr/bin/curl -fL "$PIP_URL" -o "$pip_wheel" + echo "${PIP_SHA256} ${pip_wheel}" | /usr/bin/shasum -a 256 -c - + "$python_bin" -I "${TOOLSDIR}/update_ensurepip.py" "$pip_wheel" + "$python_bin" -I -m ensurepip --upgrade --default-pip + "$python_bin" -I -m pip --isolated --disable-pip-version-check install \ + --no-cache-dir wheel -r "${TOOLSDIR}/requirements_${TYPE}.txt" + /bin/rm -rf "$pip_dir" + + # Installing extras above creates new console scripts after the helper's + # original relocation pass. Make their shebangs relocatable before moving. + /usr/bin/python3 -I - "$rp_extract" "${FRAMEWORKDIR}/Python.framework" "$PYTHON_BIN_VERSION" <<'PY' +import sys + +sys.path.insert(0, sys.argv[1]) +from locallibs.fix import fix_script_shebangs + +if not fix_script_shebangs(sys.argv[2], sys.argv[3]): + raise SystemExit("Failed to relocate installed console scripts") +PY + /bin/mv "${FRAMEWORKDIR}/Python.framework" \ "$TOOLSDIR/$TYPE/payload${FRAMEWORKDIR}/Python3.framework" } @@ -202,6 +231,7 @@ codesign_framework() { /usr/bin/codesign -s "$identity" --deep "${cs_args[@]}" "$versioned/Resources/Python.app" /usr/bin/codesign -s "$identity" "${cs_args[@]}" "$versioned/Python" + /usr/bin/codesign --verify --deep --strict --verbose=2 "$framework_root" /usr/sbin/spctl -a -vvvv "$versioned/Python" || true } @@ -284,6 +314,11 @@ download_tool munki-pkg "$MP_SHA" \ "$MP_ZIP" "$MP_BINDIR" build_framework install_sitecustomize +# Runtime checks can create bytecode caches in the framework. Finish all such +# writes before signing so they cannot invalidate its sealed resources. +/usr/bin/env -u SSL_CERT_FILE -u SSL_CERT_DIR \ + "$TOOLSDIR/$TYPE/payload${FRAMEWORKDIR}/Python3.framework/Versions/${PYTHON_BIN_VERSION}/bin/python${PYTHON_BIN_VERSION}" \ + -I "${TOOLSDIR}/verify_framework.py" codesign_framework build_pkg notarize_and_staple diff --git a/requirements_recommended.txt b/requirements_recommended.txt index 6274b06..30d3c57 100644 --- a/requirements_recommended.txt +++ b/requirements_recommended.txt @@ -2,12 +2,12 @@ asn1crypto==1.5.1 aspy.yaml==1.3.0 attrs==26.1.0 black==26.3.1 -certifi==2026.4.22 +certifi==2026.10.19 cffi==2.0.0 cfgv==3.5.0 charset-normalizer==3.4.7 click==8.3.3 -distlib==0.4.0 +distlib==0.4.4 docklib==2.0.0 entrypoints==0.4 filelock==3.29.0 @@ -19,13 +19,15 @@ isort==8.0.1 mccabe==0.7.0 mypy-extensions==1.1.0 nodeenv==1.10.0 -packaging==26.2 +packaging==26.3 pathspec==1.1.1 platformdirs==4.9.6 +pip==26.2.2 pre-commit==4.6.0 pycodestyle==2.14.0 pycparser==3.0 pyflakes==3.4.0 +python-discovery==1.6.4 pyobjc==12.1 PyYAML==6.0.3 requests==2.34.0 @@ -33,5 +35,5 @@ six==1.17.0 tokenize-rt==6.2.0 tomli==2.4.1 urllib3==2.7.0 -virtualenv==21.3.2 +virtualenv==21.14.13 xattr==1.3.0 diff --git a/update_ensurepip.py b/update_ensurepip.py new file mode 100644 index 0000000..7ca2b16 --- /dev/null +++ b/update_ensurepip.py @@ -0,0 +1,31 @@ +"""Replace ensurepip's bundled pip so offline venv creation uses fixed CAs.""" + +import ensurepip +import re +import shutil +import sys +from pathlib import Path + + +def main(): + wheel = Path(sys.argv[1]) + version = wheel.name.removeprefix("pip-").removesuffix("-py3-none-any.whl") + source = Path(ensurepip.__file__) + updated, count = re.subn( + r'(?m)^_PIP_VERSION = "[^"]+"$', + f'_PIP_VERSION = "{version}"', + source.read_text(), + ) + if count != 1: + raise RuntimeError(f"Unrecognized ensurepip version declaration in {source}") + bundled = source.parent / "_bundled" + shutil.copyfile(wheel, bundled / wheel.name) + source.write_text(updated) + for old_wheel in bundled.glob("pip-*.whl"): + if old_wheel.name != wheel.name: + old_wheel.unlink() + print(f"ensurepip now bundles pip {version}") + + +if __name__ == "__main__": + main() diff --git a/verify_framework.py b/verify_framework.py new file mode 100644 index 0000000..664512e --- /dev/null +++ b/verify_framework.py @@ -0,0 +1,91 @@ +"""Smoke-test the built framework, including offline pip bootstrap paths.""" + +import hashlib +import importlib.metadata +import os +import ssl +import subprocess +import sys +import tempfile +import urllib.request +from pathlib import Path + +import certifi +import objc +import requests +import xattr +import yaml +from packaging.version import Version + + +# emSign ECC Root CA - G3, removed by certifi 2026.10.19. Other emSign roots +# remain trusted by that release and must not be rejected just by their names. +REVOKED_ROOT = "86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b" +PIP_CHECK = """ +import hashlib +import ssl +import pip +from pip._vendor import certifi +if tuple(map(int, pip.__version__.split('.'))) < (26, 2, 2): + raise RuntimeError('Vulnerable pip: ' + pip.__version__) +context = ssl.create_default_context(cafile=certifi.where()) +if {revoked!r} in {{hashlib.sha256(c).hexdigest() for c in context.get_ca_certs(binary_form=True)}}: + raise RuntimeError('pip still trusts the compromised emSign root') +print('Verified pip', pip.__version__, certifi.where(), flush=True) +""".format(revoked=REVOKED_ROOT) + + +def run(*args): + subprocess.run(args, check=True) + + +def check_context(context): + fingerprints = { + hashlib.sha256(cert).hexdigest() + for cert in context.get_ca_certs(binary_form=True) + } + if not fingerprints or REVOKED_ROOT in fingerprints: + raise RuntimeError("Missing CA bundle or compromised emSign root still trusted") + + +def main(): + print(f"Verifying framework: {sys.version}", flush=True) + for package, minimum in (("certifi", "2026.10.19"), ("pip", "26.2.2")): + version = importlib.metadata.version(package) + if Version(version) < Version(minimum): + raise RuntimeError(f"{package} {version} is older than {minimum}") + print(f"Verified {package} {version}", flush=True) + + if os.environ.get("SSL_CERT_FILE") != certifi.where(): + raise RuntimeError("sitecustomize did not select the bundled certifi CA store") + check_context(ssl.create_default_context()) + check_context(ssl.create_default_context(cafile=certifi.where())) + run(sys.executable, "-I", "-c", PIP_CHECK) + run(sys.executable, "-I", "-m", "pip", "check") + # Exercise relocated console scripts as well as python -m entry points. + run(str(Path(sys.prefix) / "bin/pip"), "--version") + run(str(Path(sys.prefix) / "bin/virtualenv"), "--version") + + with tempfile.TemporaryDirectory(prefix="macadmins-python-verify-") as temporary: + root = Path(temporary) + # Neither command may fetch a newer pip to hide an outdated seed wheel. + run(sys.executable, "-I", "-m", "venv", str(root / "venv")) + run(str(root / "venv/bin/python"), "-I", "-c", PIP_CHECK) + run( + sys.executable, "-I", "-m", "virtualenv", + "--no-download", "--no-periodic-update", + "--app-data", str(root / "app-data"), str(root / "virtualenv"), + ) + run(str(root / "virtualenv/bin/python"), "-I", "-c", PIP_CHECK) + + for url in ("https://www.python.org/", "https://pypi.org/simple/pip/"): + with urllib.request.urlopen(url, timeout=30) as response: + if response.status != 200: + raise RuntimeError(f"HTTPS check failed: {url}") + requests.get(url, timeout=30).raise_for_status() + print(f"Verified stdlib and Requests HTTPS: {url}", flush=True) + print("Framework verification passed", flush=True) + + +if __name__ == "__main__": + main()