From 1d74e03f0da1687d2d4265cba5beeb17a168abbe Mon Sep 17 00:00:00 2001 From: Naga Nandyala Date: Fri, 24 Jul 2026 10:45:24 +1000 Subject: [PATCH] {Packaging} Harden launchers against CWD imports --- scripts/curl_install_pypi/install.py | 2 +- .../release/macos/templates/az_launcher.sh.in | 2 +- scripts/release/rpm/azure-cli.spec | 3 +- .../release/tests/test_launcher_security.py | 131 ++++++++++++++++++ src/azure-cli/az.bat | 12 +- src/azure-cli/azps.ps1 | 15 +- .../cognitiveservices/custom.py | 7 +- 7 files changed, 157 insertions(+), 15 deletions(-) create mode 100644 scripts/release/tests/test_launcher_security.py diff --git a/scripts/curl_install_pypi/install.py b/scripts/curl_install_pypi/install.py index 09a3df24dc3..3ca16ce7ee9 100644 --- a/scripts/curl_install_pypi/install.py +++ b/scripts/curl_install_pypi/install.py @@ -26,7 +26,7 @@ from urllib.request import urlopen AZ_DISPATCH_TEMPLATE = """#!/usr/bin/env bash -{install_dir}/bin/python -m azure.cli "$@" +{install_dir}/bin/python -c 'import os, runpy, sys; cwd = os.path.normcase(os.path.realpath(os.getcwd())); sys.path[:] = [path for path in sys.path if os.path.normcase(os.path.realpath(path or os.curdir)) != cwd]; runpy.run_module("azure.cli.__main__", run_name="__main__", alter_sys=True)' "$@" """ VIRTUALENV_VERSION = '16.7.11' diff --git a/scripts/release/macos/templates/az_launcher.sh.in b/scripts/release/macos/templates/az_launcher.sh.in index f11971e0f6d..8969582a36b 100644 --- a/scripts/release/macos/templates/az_launcher.sh.in +++ b/scripts/release/macos/templates/az_launcher.sh.in @@ -62,4 +62,4 @@ fi export PYTHONPATH="$AZURE_CLI_SITE_PACKAGES" export AZ_INSTALLER="$INSTALLER" -exec "$PYTHON" -sm azure.cli "$@" +exec "$PYTHON" -s "$AZURE_CLI_SITE_PACKAGES/azure/cli/__main__.py" "$@" diff --git a/scripts/release/rpm/azure-cli.spec b/scripts/release/rpm/azure-cli.spec index 91e01b15f94..0e76806dbcb 100644 --- a/scripts/release/rpm/azure-cli.spec +++ b/scripts/release/rpm/azure-cli.spec @@ -74,7 +74,8 @@ printf "#!/usr/bin/env bash bin_dir=\`cd \"\$(dirname \"\$BASH_SOURCE[0]\")\"; pwd\` python_cmd=%{python_cmd} if command -v ${python_version} &>/dev/null; then python_cmd=${python_version}; fi -AZ_INSTALLER=RPM PYTHONPATH=\"\$bin_dir/../lib64/az/lib/${python_version}/site-packages\" \$python_cmd -sm azure.cli \"\$@\" +site_packages=\"\$bin_dir/../lib64/az/lib/${python_version}/site-packages\" +AZ_INSTALLER=RPM PYTHONPATH=\"\$site_packages\" \$python_cmd -s \"\$site_packages/azure/cli/__main__.py\" \"\$@\" " > %{buildroot}%{_bindir}/az rm %{buildroot}%{cli_lib_dir}/bin/python* %{buildroot}%{cli_lib_dir}/bin/pip* diff --git a/scripts/release/tests/test_launcher_security.py b/scripts/release/tests/test_launcher_security.py new file mode 100644 index 00000000000..3814f8f4672 --- /dev/null +++ b/scripts/release/tests/test_launcher_security.py @@ -0,0 +1,131 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import os +from pathlib import Path +import runpy +import shutil +import subprocess +import sys +import tempfile +import unittest + + +REPO_ROOT = Path(__file__).resolve().parents[3] +MACOS_LAUNCHER = REPO_ROOT / 'scripts/release/macos/templates/az_launcher.sh.in' +CURL_INSTALLER = REPO_ROOT / 'scripts/curl_install_pypi/install.py' + + +class LauncherSecurityTest(unittest.TestCase): + + def _write_cli_package(self, site_packages, exit_code): + cli_dir = site_packages / 'azure' / 'cli' + cli_dir.mkdir(parents=True) + (cli_dir.parent / '__init__.py').write_text('', encoding='utf-8') + (cli_dir / '__init__.py').write_text('', encoding='utf-8') + (cli_dir / '__main__.py').write_text( + 'import json, sys\n' + 'print(json.dumps(sys.argv[1:]))\n' + f'raise SystemExit({exit_code})\n', + encoding='utf-8', + ) + + def _write_attacker_module(self, attacker_dir): + attacker_dir.mkdir() + (attacker_dir / 'azure.py').write_text( + 'from pathlib import Path\n' + 'Path("PWNED").write_text("yes", encoding="utf-8")\n', + encoding='utf-8', + ) + + def test_macos_launcher_ignores_cwd_module(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + install_dir = root / 'install' + launcher_dir = install_dir / 'libexec' / 'bin' + site_packages = install_dir / 'libexec' / 'lib' / \ + f'python{sys.version_info.major}.{sys.version_info.minor}' / 'site-packages' + attacker_dir = root / 'attacker' + launcher_dir.mkdir(parents=True) + self._write_cli_package(site_packages, exit_code=23) + self._write_attacker_module(attacker_dir) + + launcher = launcher_dir / 'az' + launcher.write_text( + MACOS_LAUNCHER.read_text(encoding='utf-8') + .replace('{PYTHON_MAJOR_MINOR}', f'{sys.version_info.major}.{sys.version_info.minor}') + .replace('{PYTHON_BIN}', f'python{sys.version_info.major}'), + encoding='utf-8', + ) + launcher.chmod(0o755) + + result = subprocess.run( + [str(launcher), 'alpha', 'two words'], + cwd=attacker_dir, + env={**os.environ, 'AZ_PYTHON': sys.executable}, + check=False, + capture_output=True, + text=True, + ) + + self.assertEqual(23, result.returncode, result.stderr) + self.assertEqual('["alpha", "two words"]', result.stdout.strip()) + self.assertFalse((attacker_dir / 'PWNED').exists()) + + def test_curl_dispatch_ignores_cwd_module(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + install_dir = root / 'install' + bin_dir = install_dir / 'bin' + site_packages = install_dir / 'site-packages' + attacker_dir = root / 'attacker' + bin_dir.mkdir(parents=True) + self._write_cli_package(site_packages, exit_code=31) + self._write_attacker_module(attacker_dir) + + python_wrapper = bin_dir / 'python' + python_wrapper.write_text( + '#!/usr/bin/env bash\n' + f'PYTHONPATH="{site_packages}" exec "{sys.executable}" "$@"\n', + encoding='utf-8', + ) + python_wrapper.chmod(0o755) + + dispatch_template = runpy.run_path(str(CURL_INSTALLER))['AZ_DISPATCH_TEMPLATE'] + launcher = root / 'az' + launcher.write_text(dispatch_template.format(install_dir=install_dir), encoding='utf-8') + launcher.chmod(0o755) + + result = subprocess.run( + [str(launcher), 'alpha', 'two words'], + cwd=attacker_dir, + check=False, + capture_output=True, + text=True, + ) + + self.assertEqual(31, result.returncode, result.stderr) + self.assertEqual('["alpha", "two words"]', result.stdout.strip()) + self.assertFalse((attacker_dir / 'PWNED').exists()) + + def test_production_launchers_do_not_use_unsafe_module_entry(self): + launcher_paths = [ + REPO_ROOT / 'src/azure-cli/az.bat', + REPO_ROOT / 'src/azure-cli/azps.ps1', + REPO_ROOT / 'scripts/curl_install_pypi/install.py', + REPO_ROOT / 'scripts/release/macos/templates/az_launcher.sh.in', + REPO_ROOT / 'scripts/release/rpm/azure-cli.spec', + REPO_ROOT / 'src/azure-cli/azure/cli/command_modules/cognitiveservices/custom.py', + ] + + for launcher_path in launcher_paths: + with self.subTest(launcher_path=launcher_path): + contents = launcher_path.read_text(encoding='utf-8') + self.assertNotIn('-m azure.cli', contents) + self.assertNotIn("'-m', 'azure.cli'", contents) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/src/azure-cli/az.bat b/src/azure-cli/az.bat index c16942a2e42..8d7a6c71271 100644 --- a/src/azure-cli/az.bat +++ b/src/azure-cli/az.bat @@ -1,11 +1,15 @@ @echo off setlocal -SET PYTHONPATH=%~dp0\src;%PYTHONPATH% -SET AZ_INSTALLER=PIP +IF EXIST "%~dp0src\azure\cli\__main__.py" ( + SET "PYTHONPATH=%~dp0src;%PYTHONPATH%" +) ELSE ( + SET "PYTHONPATH=%~dp0;%PYTHONPATH%" +) +SET "AZ_INSTALLER=PIP" IF EXIST "%~dp0\python.exe" ( - "%~dp0\python.exe" -m azure.cli %* + "%~dp0\python.exe" -c "import os,runpy,sys; cwd=os.path.normcase(os.path.realpath(os.getcwd())); sys.path[:]=[path for path in sys.path if os.path.normcase(os.path.realpath(path or os.curdir)) != cwd]; runpy.run_module('azure.cli.__main__', run_name='__main__', alter_sys=True)" %* ) ELSE ( - python -m azure.cli %* + python -c "import os,runpy,sys; cwd=os.path.normcase(os.path.realpath(os.getcwd())); sys.path[:]=[path for path in sys.path if os.path.normcase(os.path.realpath(path or os.curdir)) != cwd]; runpy.run_module('azure.cli.__main__', run_name='__main__', alter_sys=True)" %* ) diff --git a/src/azure-cli/azps.ps1 b/src/azure-cli/azps.ps1 index 9b6de0d8d0a..7a71e4403d4 100644 --- a/src/azure-cli/azps.ps1 +++ b/src/azure-cli/azps.ps1 @@ -1,13 +1,22 @@ $env:AZ_INSTALLER="PIP" +$moduleSearchPath = if (Test-Path "$PSScriptRoot\src\azure\cli\__main__.py") { + "$PSScriptRoot\src" +} +else { + $PSScriptRoot +} +$env:PYTHONPATH = "$moduleSearchPath$([IO.Path]::PathSeparator)$env:PYTHONPATH" +$launcherCode = 'import os,runpy,sys; cwd=os.path.normcase(os.path.realpath(os.getcwd())); sys.path[:]=[path for path in sys.path if os.path.normcase(os.path.realpath(path or os.curdir)) != cwd]; runpy.run_module("azure.cli.__main__", run_name="__main__", alter_sys=True)' if (Test-Path "$PSScriptRoot\python.exe") { - # Perfer python.exe in venv - & "$PSScriptRoot\python.exe" -m azure.cli $args + # Prefer python.exe in venv + & "$PSScriptRoot\python.exe" -c $launcherCode $args } else { # Run system python.exe - python.exe -m azure.cli $args + python.exe -c $launcherCode $args } +exit $LASTEXITCODE # SIG # Begin signature block # MIInqgYJKoZIhvcNAQcCoIInmzCCJ5cCAQExDzANBglghkgBZQMEAgEFADB5Bgor diff --git a/src/azure-cli/azure/cli/command_modules/cognitiveservices/custom.py b/src/azure-cli/azure/cli/command_modules/cognitiveservices/custom.py index 81187d7c82c..305696ece4d 100644 --- a/src/azure-cli/azure/cli/command_modules/cognitiveservices/custom.py +++ b/src/azure-cli/azure/cli/command_modules/cognitiveservices/custom.py @@ -765,11 +765,8 @@ def _push_image_to_registry(_cmd, image_name, registry_name): # pylint: disable try: # Login to ACR using Azure CLI credentials - # We use sys.executable + '-m azure.cli' instead of 'az' command to ensure - # compatibility when developing/testing the CLI from source. In development - # environments, 'az' may not be in PATH or may point to a different installation. - # In production, sys.executable will still correctly invoke the installed CLI. - az_cmd = [sys.executable, '-m', 'azure.cli', 'acr', 'login', '--name', registry_name] + azure_cli_main = Path(__file__).resolve().parents[2] / '__main__.py' + az_cmd = [sys.executable, str(azure_cli_main), 'acr', 'login', '--name', registry_name] logger.info("Logging into ACR: %s", registry_name) result = subprocess.run(