You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
490 lines
17 KiB
490 lines
17 KiB
#!/bin/bash
|
|
|
|
# Copyright (c) 2024, crasowas.
|
|
#
|
|
# Use of this source code is governed by a MIT-style license
|
|
# that can be found in the LICENSE file or at
|
|
# https://opensource.org/licenses/MIT.
|
|
|
|
set -e
|
|
|
|
# Absolute path of the script and the tool's root directory
|
|
script_path="$(realpath "$0")"
|
|
tool_root_path="$(dirname "$script_path")"
|
|
|
|
# Load common constants and utils
|
|
source "$tool_root_path/Common/constants.sh"
|
|
source "$tool_root_path/Common/utils.sh"
|
|
|
|
# Force replace the existing privacy manifest when the `-f` option is enabled
|
|
force=false
|
|
|
|
# Enable silent mode to disable build output when the `-s` option is enabled
|
|
silent=false
|
|
|
|
# Parse command-line options
|
|
while getopts ":fs" opt; do
|
|
case $opt in
|
|
f)
|
|
force=true
|
|
;;
|
|
s)
|
|
silent=true
|
|
;;
|
|
\?)
|
|
echo "Invalid option: -$OPTARG" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
done
|
|
|
|
shift $((OPTIND - 1))
|
|
|
|
# Path of the app produced by the build process
|
|
app_path="${TARGET_BUILD_DIR}/${WRAPPER_NAME}"
|
|
|
|
# Check if the app exists
|
|
if [ ! -d "$app_path" ] || [[ "$app_path" != *.app ]]; then
|
|
echo "Unable to find the app: $app_path"
|
|
exit 1
|
|
fi
|
|
|
|
# Check if the app is iOS or macOS
|
|
is_ios_app=true
|
|
frameworks_dir="$app_path/Frameworks"
|
|
if [ -d "$app_path/Contents/MacOS" ]; then
|
|
is_ios_app=false
|
|
frameworks_dir="$app_path/Contents/Frameworks"
|
|
fi
|
|
|
|
# Default template directories
|
|
templates_dir="$tool_root_path/Templates"
|
|
user_templates_dir="$tool_root_path/Templates/UserTemplates"
|
|
|
|
# Use user-defined app privacy manifest template if it exists, otherwise fallback to default
|
|
app_template_file="$user_templates_dir/$APP_TEMPLATE_FILE_NAME"
|
|
if [ ! -f "$app_template_file" ]; then
|
|
app_template_file="$templates_dir/$APP_TEMPLATE_FILE_NAME"
|
|
fi
|
|
|
|
# Use user-defined framework privacy manifest template if it exists, otherwise fallback to default
|
|
framework_template_file="$user_templates_dir/$FRAMEWORK_TEMPLATE_FILE_NAME"
|
|
if [ ! -f "$framework_template_file" ]; then
|
|
framework_template_file="$templates_dir/$FRAMEWORK_TEMPLATE_FILE_NAME"
|
|
fi
|
|
|
|
# Disable build output in silent mode
|
|
if [ "$silent" == false ]; then
|
|
# Script used to generate the privacy access report
|
|
report_script="$tool_root_path/Report/report.sh"
|
|
# An array to record template usage for generating the privacy access report
|
|
template_usage_records=()
|
|
|
|
# Build output directory
|
|
build_dir="$tool_root_path/Build/${PRODUCT_NAME}-${CONFIGURATION}_${MARKETING_VERSION}_${CURRENT_PROJECT_VERSION}_$(date +%Y%m%d%H%M%S)"
|
|
# Ensure the build directory exists
|
|
mkdir -p "$build_dir"
|
|
|
|
# Redirect both stdout and stderr to a log file while keeping console output
|
|
exec > >(tee "$build_dir/fix.log") 2>&1
|
|
fi
|
|
|
|
# Get the path to the `Info.plist` file for the specified app or framework
|
|
function get_plist_file() {
|
|
local path="$1"
|
|
local version_path="$2"
|
|
local plist_file=""
|
|
|
|
if [[ "$path" == *.app ]]; then
|
|
if [ "$is_ios_app" == true ]; then
|
|
plist_file="$path/Info.plist"
|
|
else
|
|
plist_file="$path/Contents/Info.plist"
|
|
fi
|
|
elif [[ "$path" == *.framework ]]; then
|
|
local framework_path="$(get_framework_path "$path" "$version_path")"
|
|
|
|
if [ "$is_ios_app" == true ]; then
|
|
plist_file="$framework_path/Info.plist"
|
|
else
|
|
plist_file="$framework_path/Resources/Info.plist"
|
|
fi
|
|
fi
|
|
|
|
echo "$plist_file"
|
|
}
|
|
|
|
# Get the path to the executable for the specified app or framework
|
|
function get_executable_path() {
|
|
local path="$1"
|
|
local version_path="$2"
|
|
local executable_path=""
|
|
|
|
local plist_file="$(get_plist_file "$path" "$version_path")"
|
|
local executable_name="$(get_plist_executable "$plist_file")"
|
|
|
|
if [[ "$path" == *.app ]]; then
|
|
if [ "$is_ios_app" == true ]; then
|
|
executable_path="$path/$executable_name"
|
|
else
|
|
executable_path="$path/Contents/MacOS/$executable_name"
|
|
fi
|
|
elif [[ "$path" == *.framework ]]; then
|
|
local framework_path="$(get_framework_path "$path" "$version_path")"
|
|
executable_path="$framework_path/$executable_name"
|
|
fi
|
|
|
|
echo "$executable_path"
|
|
}
|
|
|
|
# Analyze the specified binary file for API symbols and their categories
|
|
function analyze_binary_file() {
|
|
local path="$1"
|
|
local -a results=()
|
|
|
|
# Check if the API symbol exists in the binary file using `nm` and `strings`
|
|
local nm_output=$(nm "$path" 2>/dev/null | xcrun swift-demangle)
|
|
local strings_output=$(strings "$path")
|
|
local combined_output="$nm_output"$'\n'"$strings_output"
|
|
|
|
for api_symbol in "${API_SYMBOLS[@]}"; do
|
|
local substrings=($(split_string_by_delimiter "$api_symbol"))
|
|
local category=${substrings[0]}
|
|
local api=${substrings[1]}
|
|
|
|
if echo "$combined_output" | grep -E "$api\$" >/dev/null; then
|
|
local index=-1
|
|
for ((i=0; i < ${#results[@]}; i++)); do
|
|
local result="${results[i]}"
|
|
local result_substrings=($(split_string_by_delimiter "$result"))
|
|
# If the category matches an existing result, update it
|
|
if [ "$category" == "${result_substrings[0]}" ]; then
|
|
index=i
|
|
results[i]="${result_substrings[0]}$DELIMITER${result_substrings[1]},$api$DELIMITER${result_substrings[2]}"
|
|
break
|
|
fi
|
|
done
|
|
|
|
# If no matching category found, add a new result
|
|
if [[ $index -eq -1 ]]; then
|
|
results+=("$category$DELIMITER$api$DELIMITER$(encode_path "$path")")
|
|
fi
|
|
fi
|
|
done
|
|
|
|
echo "${results[@]}"
|
|
}
|
|
|
|
# Analyze API usage in a binary file
|
|
function analyze_api_usage() {
|
|
local path="$1"
|
|
local version_path="$2"
|
|
local -a results=()
|
|
|
|
local binary_file="$(get_executable_path "$path" "$version_path")"
|
|
|
|
if [ -f "$binary_file" ]; then
|
|
results+=($(analyze_binary_file "$binary_file"))
|
|
fi
|
|
|
|
echo "${results[@]}"
|
|
}
|
|
|
|
|
|
|
|
# Get unique categories from analysis results
|
|
function get_categories() {
|
|
local results=("$@")
|
|
local -a categories=()
|
|
|
|
for result in "${results[@]}"; do
|
|
local substrings=($(split_string_by_delimiter "$result"))
|
|
local category=${substrings[0]}
|
|
if [[ ! "${categories[@]}" =~ "$category" ]]; then
|
|
categories+=("$category")
|
|
fi
|
|
done
|
|
|
|
echo "${categories[@]}"
|
|
}
|
|
|
|
# Get template file for the specified app or framework
|
|
function get_template_file() {
|
|
local path="$1"
|
|
local version_path="$2"
|
|
local template_file=""
|
|
|
|
if [[ "$path" == *.app ]]; then
|
|
template_file="$app_template_file"
|
|
else
|
|
# Give priority to the user-defined framework privacy manifest template
|
|
local dep_name="$(get_dependency_name "$path")"
|
|
if [ -n "$version_path" ]; then
|
|
dep_name="$dep_name.$(basename "$version_path")"
|
|
fi
|
|
|
|
local dep_template_file="$user_templates_dir/${dep_name}.xcprivacy"
|
|
if [ -f "$dep_template_file" ]; then
|
|
template_file="$dep_template_file"
|
|
else
|
|
template_file="$framework_template_file"
|
|
fi
|
|
fi
|
|
|
|
echo "$template_file"
|
|
}
|
|
|
|
# Check if the specified template file should be modified
|
|
#
|
|
# The following template files will be modified based on analysis:
|
|
# * Templates/AppTemplate.xcprivacy
|
|
# * Templates/FrameworkTemplate.xcprivacy
|
|
# * Templates/UserTemplates/FrameworkTemplate.xcprivacy
|
|
function is_template_modifiable() {
|
|
local template_file="$1"
|
|
|
|
local template_file_name="$(basename "$template_file")"
|
|
|
|
if [[ "$template_file" != "$user_templates_dir"* ]] || [ "$template_file_name" == "$FRAMEWORK_TEMPLATE_FILE_NAME" ]; then
|
|
return 0
|
|
else
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# Check if `Hardened Runtime` is enabled for the specified path
|
|
function is_hardened_runtime_enabled() {
|
|
local path="$1"
|
|
|
|
# Check environment variable first
|
|
if [ "${ENABLE_HARDENED_RUNTIME:-}" == "YES" ]; then
|
|
return 0
|
|
fi
|
|
|
|
# Check the code signature flags if environment variable is not set
|
|
local flags=$(codesign -dvvv "$path" 2>&1 | grep "flags=")
|
|
if echo "$flags" | grep -q "runtime"; then
|
|
return 0
|
|
else
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# Re-sign the target app or framework if code signing is enabled
|
|
function resign() {
|
|
local path="$1"
|
|
|
|
if [ -n "${EXPANDED_CODE_SIGN_IDENTITY:-}" ] && [ "${CODE_SIGNING_REQUIRED:-}" != "NO" ] && [ "${CODE_SIGNING_ALLOWED:-}" != "NO" ]; then
|
|
echo "Re-signing $path with Identity ${EXPANDED_CODE_SIGN_IDENTITY_NAME:-$EXPANDED_CODE_SIGN_IDENTITY}"
|
|
|
|
local codesign_cmd="/usr/bin/codesign --force --sign ${EXPANDED_CODE_SIGN_IDENTITY} ${OTHER_CODE_SIGN_FLAGS:-} --preserve-metadata=identifier,entitlements"
|
|
|
|
if [ "$is_ios_app" == true ]; then
|
|
$codesign_cmd "$path"
|
|
else
|
|
if is_hardened_runtime_enabled "$path"; then
|
|
codesign_cmd="$codesign_cmd -o runtime"
|
|
fi
|
|
|
|
if [ -d "$path/Contents/MacOS" ]; then
|
|
find "$path/Contents/MacOS" -type f -name "*.dylib" | while read -r dylib_file; do
|
|
$codesign_cmd "$dylib_file"
|
|
done
|
|
fi
|
|
|
|
$codesign_cmd "$path"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# Fix the privacy manifest for the app or specified framework
|
|
# To accelerate the build, existing privacy manifests will be left unchanged unless the `-f` option is enabled
|
|
# After fixing, the app or framework will be automatically re-signed
|
|
function fix() {
|
|
local path="$1"
|
|
local version_path="$2"
|
|
local force_resign="$3"
|
|
local privacy_manifest_file=""
|
|
|
|
if [[ "$path" == *.app ]]; then
|
|
# Per the documentation, the privacy manifest should be placed at the root of the app’s bundle for iOS, while for macOS, it should be located in `Contents/Resources/` within the app’s bundle
|
|
# Reference: https://developer.apple.com/documentation/bundleresources/adding-a-privacy-manifest-to-your-app-or-third-party-sdk#Add-a-privacy-manifest-to-your-app
|
|
if [ "$is_ios_app" == true ]; then
|
|
privacy_manifest_file="$path/$PRIVACY_MANIFEST_FILE_NAME"
|
|
else
|
|
privacy_manifest_file="$path/Contents/Resources/$PRIVACY_MANIFEST_FILE_NAME"
|
|
fi
|
|
else
|
|
# Per the documentation, the privacy manifest should be placed at the root of the iOS framework, while for a macOS framework with multiple versions, it should be located in the `Resources` directory within the corresponding version
|
|
# Some SDKs don’t follow the guideline, so we use a search-based approach for now
|
|
# Reference: https://developer.apple.com/documentation/bundleresources/adding-a-privacy-manifest-to-your-app-or-third-party-sdk#Add-a-privacy-manifest-to-your-framework
|
|
local framework_path="$(get_framework_path "$path" "$version_path")"
|
|
local privacy_manifest_files=($(search_privacy_manifest_files "$framework_path"))
|
|
privacy_manifest_file="$(get_privacy_manifest_file "${privacy_manifest_files[@]}")"
|
|
|
|
if [ -z "$privacy_manifest_file" ]; then
|
|
if [ "$is_ios_app" == true ]; then
|
|
privacy_manifest_file="$framework_path/$PRIVACY_MANIFEST_FILE_NAME"
|
|
else
|
|
privacy_manifest_file="$framework_path/Resources/$PRIVACY_MANIFEST_FILE_NAME"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# Check if the privacy manifest file exists
|
|
if [ -f "$privacy_manifest_file" ]; then
|
|
echo "💡 Found privacy manifest file: $privacy_manifest_file"
|
|
|
|
if [ "$force" == false ]; then
|
|
if [ "$force_resign" == true ]; then
|
|
resign "$path"
|
|
fi
|
|
echo "✅ Privacy manifest file already exists, skipping fix."
|
|
return
|
|
fi
|
|
else
|
|
echo "⚠️ Missing privacy manifest file!"
|
|
fi
|
|
|
|
local results=($(analyze_api_usage "$path" "$version_path"))
|
|
echo "API usage analysis result(s): ${#results[@]}"
|
|
print_array "${results[@]}"
|
|
|
|
local template_file="$(get_template_file "$path" "$version_path")"
|
|
template_usage_records+=("$(basename "$path")$version_path$DELIMITER$template_file")
|
|
|
|
# Copy the template file to the privacy manifest location, overwriting if it exists
|
|
cp "$template_file" "$privacy_manifest_file"
|
|
|
|
if is_template_modifiable "$template_file"; then
|
|
local categories=($(get_categories "${results[@]}"))
|
|
local remove_categories=()
|
|
|
|
# Check if categories is non-empty
|
|
if [[ ${#categories[@]} -gt 0 ]]; then
|
|
# Convert categories to a single space-separated string for easy matching
|
|
local categories_set=" ${categories[*]} "
|
|
|
|
# Iterate through each element in `API_CATEGORIES`
|
|
for element in "${API_CATEGORIES[@]}"; do
|
|
# If element is not found in `categories_set`, add it to `remove_categories`
|
|
if [[ ! $categories_set =~ " $element " ]]; then
|
|
remove_categories+=("$element")
|
|
fi
|
|
done
|
|
else
|
|
# If categories is empty, add all of `API_CATEGORIES` to `remove_categories`
|
|
remove_categories=("${API_CATEGORIES[@]}")
|
|
fi
|
|
|
|
# Remove extra spaces in the XML file to simplify node removal
|
|
xmllint --noblanks "$privacy_manifest_file" -o "$privacy_manifest_file"
|
|
|
|
# Build a sed command to remove all matching nodes at once
|
|
local sed_pattern=""
|
|
for category in "${remove_categories[@]}"; do
|
|
# Find the node for the current category
|
|
local remove_node="$(xmllint --xpath "//dict[string='$category']" "$privacy_manifest_file" 2>/dev/null || true)"
|
|
|
|
# If the node is found, escape special characters and append it to the sed pattern
|
|
if [ -n "$remove_node" ]; then
|
|
local escaped_node=$(echo "$remove_node" | sed 's/[\/&]/\\&/g')
|
|
sed_pattern+="s/$escaped_node//g;"
|
|
fi
|
|
done
|
|
|
|
# Apply the combined sed pattern to the file if it's not empty
|
|
if [ -n "$sed_pattern" ]; then
|
|
sed -i "" "$sed_pattern" "$privacy_manifest_file"
|
|
fi
|
|
|
|
# Reformat the XML file to restore spaces for readability
|
|
xmllint --format "$privacy_manifest_file" -o "$privacy_manifest_file"
|
|
fi
|
|
|
|
resign "$path"
|
|
|
|
echo "✅ Privacy manifest file fixed: $privacy_manifest_file."
|
|
}
|
|
|
|
# Fix privacy manifests for all frameworks
|
|
function fix_frameworks() {
|
|
if ! [ -d "$frameworks_dir" ]; then
|
|
return
|
|
fi
|
|
|
|
echo "🛠️ Fixing Frameworks..."
|
|
for path in "$frameworks_dir"/*; do
|
|
if [ -d "$path" ]; then
|
|
local dep_name="$(get_dependency_name "$path")"
|
|
local versions_dir="$path/Versions"
|
|
|
|
if [ -d "$versions_dir" ]; then
|
|
for version in $(ls -1 "$versions_dir" | grep -vE '^Current$'); do
|
|
local version_path="Versions/$version"
|
|
echo "Analyzing $dep_name ($version_path) ..."
|
|
fix "$path" "$version_path" false
|
|
echo ""
|
|
done
|
|
else
|
|
echo "Analyzing $dep_name ..."
|
|
fix "$path" "" false
|
|
echo ""
|
|
fi
|
|
fi
|
|
done
|
|
}
|
|
|
|
# Fix the privacy manifest for the app
|
|
function fix_app() {
|
|
echo "🛠️ Fixing $(basename "$app_path" .app) App..."
|
|
# Since the framework may have undergone fixes, the app must be forcefully re-signed
|
|
fix "$app_path" "" true
|
|
echo ""
|
|
}
|
|
|
|
# Generate the privacy access report for the app
|
|
function generate_report() {
|
|
local original="$1"
|
|
|
|
if [ "$silent" == true ]; then
|
|
return
|
|
fi
|
|
|
|
local app_name="$(basename "$app_path")"
|
|
local name="${app_name%.*}"
|
|
local report_name=""
|
|
|
|
# Adjust output names if the app is flagged as original
|
|
if [ "$original" == true ]; then
|
|
app_name="${name}-original.app"
|
|
report_name="report-original.html"
|
|
else
|
|
app_name="$name.app"
|
|
report_name="report.html"
|
|
fi
|
|
|
|
local target_app_path="$build_dir/$app_name"
|
|
local report_path="$build_dir/$report_name"
|
|
|
|
echo "Copy app to $target_app_path"
|
|
rsync -a "$app_path/" "$target_app_path/"
|
|
|
|
# Generate the privacy access report using the script
|
|
sh "$report_script" "$target_app_path" "$report_path" "${template_usage_records[@]}"
|
|
echo ""
|
|
}
|
|
|
|
start_time=$(date +%s)
|
|
|
|
generate_report true
|
|
fix_frameworks
|
|
fix_app
|
|
generate_report false
|
|
|
|
end_time=$(date +%s)
|
|
|
|
echo "🎉 All fixed! ⏰ $((end_time - start_time)) seconds"
|
|
echo "🌟 If you found this script helpful, please consider giving it a star on GitHub. Your support is appreciated. Thank you!"
|
|
echo "🔗 Homepage: https://github.com/crasowas/app_privacy_manifest_fixer"
|
|
echo "🐛 Report issues: https://github.com/crasowas/app_privacy_manifest_fixer/issues"
|
|
|