diff --git a/build.sh b/build.sh index c36c83f..3a009d0 100755 --- a/build.sh +++ b/build.sh @@ -7,6 +7,9 @@ # - run from a pristine git checkout (this script refuses to build from a # workspace with modified, untracked, or ignored files; see # scripts/release_lib.sh) +# - PATH must contain only directories that are not writable by other users +# (this script refuses to build with an unsafe PATH; see +# scripts/release_lib.sh) # - activate a conda environment # - have conda-build installed, e.g. `conda install conda-build` # @@ -21,13 +24,27 @@ # # cd into the snowflake-telemetry-python git root dir # export SNOWFLAKE_TELEMETRY_DIR=$(pwd) # -# For local test builds only, the workspace hygiene check can be bypassed with: +# For local test builds only, the workspace hygiene and PATH safety checks can +# be bypassed with: # export SNOWFLAKE_TELEMETRY_ALLOW_DIRTY_WORKSPACE=1 -# Never set this in the Jenkins release job. +# export SNOWFLAKE_TELEMETRY_ALLOW_UNSAFE_PATH=1 +# Never set these in the Jenkins release job. -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Resolve the repo root with shell builtins only, so no external command runs +# via the ambient PATH before assert_secure_path has validated it. +case "${BASH_SOURCE[0]}" in + */*) _script_dir="${BASH_SOURCE[0]%/*}" ;; + *) _script_dir="." ;; +esac +REPO_ROOT="$(cd "$_script_dir" && pwd)" +unset _script_dir source "${REPO_ROOT}/scripts/release_lib.sh" +# This gate must run before anything that resolves commands through the +# ambient PATH, so release builds only use tools from directories that are +# not writable by other users. +assert_secure_path || exit 1 + # Remove our own previous build output so it does not trip the hygiene gate. rm -rf ./anaconda/dist diff --git a/pypi-build.sh b/pypi-build.sh index 9a970af..d48c945 100755 --- a/pypi-build.sh +++ b/pypi-build.sh @@ -19,13 +19,27 @@ # # cd into the snowflake-telemetry-python git root dir # export SNOWFLAKE_TELEMETRY_DIR=$(pwd) # -# For local test builds only, the workspace hygiene check can be bypassed with: +# For local test builds only, the workspace hygiene and PATH safety checks can +# be bypassed with: # export SNOWFLAKE_TELEMETRY_ALLOW_DIRTY_WORKSPACE=1 -# Never set this in the Jenkins release job. +# export SNOWFLAKE_TELEMETRY_ALLOW_UNSAFE_PATH=1 +# Never set these in the Jenkins release job. -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Resolve the repo root with shell builtins only, so no external command runs +# via the ambient PATH before assert_secure_path has validated it. +case "${BASH_SOURCE[0]}" in + */*) _script_dir="${BASH_SOURCE[0]%/*}" ;; + *) _script_dir="." ;; +esac +REPO_ROOT="$(cd "$_script_dir" && pwd)" +unset _script_dir source "${REPO_ROOT}/scripts/release_lib.sh" +# This gate must run before anything that resolves commands through the +# ambient PATH, so release builds only use tools from directories that are +# not writable by other users. +assert_secure_path || exit 1 + # Remove our own previous build outputs so they do not trip the hygiene gate. rm -rf ./dist ./build venv_* src/*.egg-info diff --git a/scripts/release_lib.sh b/scripts/release_lib.sh index 2c27a28..fcaea8d 100644 --- a/scripts/release_lib.sh +++ b/scripts/release_lib.sh @@ -46,6 +46,112 @@ assert_clean_workspace() { fi } +# assert_secure_path [path_to_check] +# +# Fail if any directory on the PATH to check (default: $PATH) is unsuitable +# for a release build. A PATH entry is unsuitable when it is empty (meaning +# the current directory), relative, not an existing directory, writable by +# group or others, or owned by anyone other than root or the current user; or +# when any parent directory up to / is writable by group or others without +# the sticky bit (the sticky bit, as on /tmp, prevents non-owners from +# replacing the child) or owned by another user. +# +# The checker's own external commands run from a minimal set of base system +# directories so the result does not depend on the PATH being checked. +# +# For local test builds only, set SNOWFLAKE_TELEMETRY_ALLOW_UNSAFE_PATH=1 to +# bypass this check. Never set it in the Jenkins release job. +assert_secure_path() { + local path_to_check="${1:-$PATH}" + + if [ "${SNOWFLAKE_TELEMETRY_ALLOW_UNSAFE_PATH:-0}" = "1" ]; then + echo "WARNING: SNOWFLAKE_TELEMETRY_ALLOW_UNSAFE_PATH=1 is set; skipping the PATH safety check." >&2 + echo "WARNING: never set this for official release builds." >&2 + return 0 + fi + + # Run the checker's own external commands from base system directories + # only, so the result does not depend on the PATH being checked. PATH + # keeps its export attribute when reassigned. + local saved_path="$PATH" + PATH="/usr/bin:/bin:/usr/sbin:/sbin" + + local current_user + if ! current_user="$(id -un 2>/dev/null)" || [ -z "$current_user" ]; then + PATH="$saved_path" + echo "Unable to determine the current user; refusing to build." >&2 + return 1 + fi + + local failures="" + local old_ifs="$IFS" + IFS=':' + set -f + # shellcheck disable=SC2086 + set -- $path_to_check + set +f + IFS="$old_ifs" + + local entry + for entry in "$@"; do + if [ -z "$entry" ]; then + failures="${failures} (an empty PATH entry means the current directory)\n" + continue + fi + case "$entry" in + /*) ;; + *) + failures="${failures} $entry (relative PATH entry)\n" + continue + ;; + esac + + # Canonicalize (resolving symlinks) and confirm it is a directory. + local dir + if ! dir="$(cd "$entry" 2>/dev/null && pwd -P)"; then + failures="${failures} $entry (does not exist or is not a directory)\n" + continue + fi + + # The PATH entry itself must not be writable by group/others at all: + # anyone who can add files to it can shadow build tools. + # (-perm /022 = any of the group/other write bits set.) + if [ -n "$(find "$dir" -prune -perm /022 2>/dev/null)" ]; then + failures="${failures} $dir (writable by group or others)\n" + continue + fi + + # Walk every component up to /: each must be owned by root or the + # current user, and must not be writable by group/others unless it is + # sticky (e.g. /tmp), where the sticky bit stops non-owners from + # replacing the child. + local component="$dir" + while :; do + if [ -n "$(find "$component" -prune ! -user root ! -user "$current_user" 2>/dev/null)" ]; then + failures="${failures} $component (owned by an untrusted user, on PATH via $entry)\n" + break + fi + if [ -n "$(find "$component" -prune -perm /022 ! -perm -1000 2>/dev/null)" ]; then + failures="${failures} $component (writable by group or others without sticky bit, on PATH via $entry)\n" + break + fi + [ "$component" = "/" ] && break + component="$(dirname "$component")" + done + done + + PATH="$saved_path" + + if [ -n "$failures" ]; then + echo "Refusing to build release artifacts with an unsafe PATH." >&2 + echo "Release builds require PATH directories that are not writable by other" >&2 + echo "users. Unsafe entries:" >&2 + printf '%b' "$failures" >&2 + echo "Fix the permissions/ownership above or remove the entries from PATH." >&2 + return 1 + fi +} + # write_sha256_manifest # # Write /SHA256SUMS containing the SHA-256 digest of every file