verifyos-cli 0.2.1

A pure Rust CLI tool to scan Apple app bundles for App Store rejection risks before submission.
Documentation
use crate::parsers::bundle_scanner::find_nested_bundles;
use crate::parsers::macho_parser::MachOExecutable;
use crate::parsers::provisioning_profile::ProvisioningProfile;
use crate::rules::core::{
    AppStoreRule, ArtifactContext, RuleCategory, RuleError, RuleReport, RuleStatus, Severity,
};

pub struct NestedBundleEntitlementsRule;

impl AppStoreRule for NestedBundleEntitlementsRule {
    fn id(&self) -> &'static str {
        "RULE_NESTED_ENTITLEMENTS_MISMATCH"
    }

    fn name(&self) -> &'static str {
        "Nested Bundle Entitlements Mismatch"
    }

    fn category(&self) -> RuleCategory {
        RuleCategory::Entitlements
    }

    fn severity(&self) -> Severity {
        Severity::Error
    }

    fn recommendation(&self) -> &'static str {
        "Ensure embedded bundles have entitlements matching their embedded provisioning profiles."
    }

    fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
        let bundles = find_nested_bundles(artifact.app_bundle_path)
            .map_err(|_| crate::rules::entitlements::EntitlementsError::ParseFailure)?;

        if bundles.is_empty() {
            return Ok(RuleReport {
                status: RuleStatus::Pass,
                message: Some("No nested bundles found".to_string()),
                evidence: None,
            });
        }

        let mut mismatches = Vec::new();

        for bundle in bundles {
            let executable_path = resolve_executable_path(&bundle.bundle_path);
            let Some(executable_path) = executable_path else {
                continue;
            };

            let provisioning_path = bundle.bundle_path.join("embedded.mobileprovision");
            if !provisioning_path.exists() {
                continue;
            }

            let macho = MachOExecutable::from_file(&executable_path)
                .map_err(crate::rules::entitlements::EntitlementsError::MachO)
                .map_err(RuleError::Entitlements)?;
            let Some(entitlements_xml) = macho.entitlements else {
                continue;
            };

            let entitlements =
                crate::parsers::plist_reader::InfoPlist::from_bytes(entitlements_xml.as_bytes())
                    .map_err(|_| crate::rules::entitlements::EntitlementsError::ParseFailure)?;

            let profile = ProvisioningProfile::from_embedded_file(&provisioning_path)
                .map_err(RuleError::Provisioning)?;

            let mut local_mismatches = Vec::new();

            if let Some(app_aps) = entitlements.get_string("aps-environment") {
                match profile.entitlements.get_string("aps-environment") {
                    Some(prov_aps) if prov_aps != app_aps => local_mismatches.push(format!(
                        "aps-environment app={} profile={}",
                        app_aps, prov_aps
                    )),
                    None => local_mismatches.push("aps-environment missing in profile".to_string()),
                    _ => {}
                }
            }

            let keychain_diff = diff_string_array(
                &entitlements,
                &profile.entitlements,
                "keychain-access-groups",
            );
            if !keychain_diff.is_empty() {
                local_mismatches.push(format!(
                    "keychain-access-groups missing in profile: {}",
                    keychain_diff.join(", ")
                ));
            }

            let icloud_diff = diff_string_array(
                &entitlements,
                &profile.entitlements,
                "com.apple.developer.icloud-container-identifiers",
            );
            if !icloud_diff.is_empty() {
                local_mismatches.push(format!(
                    "iCloud containers missing in profile: {}",
                    icloud_diff.join(", ")
                ));
            }

            if !local_mismatches.is_empty() {
                mismatches.push(format!(
                    "{}: {}",
                    bundle.display_name,
                    local_mismatches.join("; ")
                ));
            }
        }

        if mismatches.is_empty() {
            return Ok(RuleReport {
                status: RuleStatus::Pass,
                message: Some("Nested bundle entitlements match profiles".to_string()),
                evidence: None,
            });
        }

        Ok(RuleReport {
            status: RuleStatus::Fail,
            message: Some("Nested bundle entitlements mismatch".to_string()),
            evidence: Some(mismatches.join(" | ")),
        })
    }
}

pub struct NestedBundleDebugEntitlementRule;

impl AppStoreRule for NestedBundleDebugEntitlementRule {
    fn id(&self) -> &'static str {
        "RULE_NESTED_DEBUG_ENTITLEMENT"
    }

    fn name(&self) -> &'static str {
        "Nested Bundle Debug Entitlement"
    }

    fn category(&self) -> RuleCategory {
        RuleCategory::Entitlements
    }

    fn severity(&self) -> Severity {
        Severity::Error
    }

    fn recommendation(&self) -> &'static str {
        "Remove get-task-allow from embedded frameworks/extensions."
    }

    fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
        let bundles = find_nested_bundles(artifact.app_bundle_path)
            .map_err(|_| crate::rules::entitlements::EntitlementsError::ParseFailure)?;

        if bundles.is_empty() {
            return Ok(RuleReport {
                status: RuleStatus::Pass,
                message: Some("No nested bundles found".to_string()),
                evidence: None,
            });
        }

        let mut offenders = Vec::new();

        for bundle in bundles {
            let executable_path = resolve_executable_path(&bundle.bundle_path);
            let Some(executable_path) = executable_path else {
                continue;
            };

            let macho = MachOExecutable::from_file(&executable_path)
                .map_err(crate::rules::entitlements::EntitlementsError::MachO)
                .map_err(RuleError::Entitlements)?;
            let Some(entitlements_xml) = macho.entitlements else {
                continue;
            };

            let entitlements =
                crate::parsers::plist_reader::InfoPlist::from_bytes(entitlements_xml.as_bytes())
                    .map_err(|_| crate::rules::entitlements::EntitlementsError::ParseFailure)?;

            if let Some(true) = entitlements.get_bool("get-task-allow") {
                offenders.push(bundle.display_name);
            }
        }

        if offenders.is_empty() {
            return Ok(RuleReport {
                status: RuleStatus::Pass,
                message: Some("No debug entitlements in nested bundles".to_string()),
                evidence: None,
            });
        }

        Ok(RuleReport {
            status: RuleStatus::Fail,
            message: Some("Debug entitlements found in nested bundles".to_string()),
            evidence: Some(offenders.join(", ")),
        })
    }
}

fn resolve_executable_path(bundle_path: &std::path::Path) -> Option<std::path::PathBuf> {
    let bundle_name = bundle_path
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("")
        .trim_end_matches(".app")
        .trim_end_matches(".appex")
        .trim_end_matches(".framework");

    if bundle_name.is_empty() {
        return None;
    }

    let executable_path = bundle_path.join(bundle_name);
    if executable_path.exists() {
        Some(executable_path)
    } else {
        None
    }
}

fn diff_string_array(
    entitlements: &crate::parsers::plist_reader::InfoPlist,
    profile: &crate::parsers::plist_reader::InfoPlist,
    key: &str,
) -> Vec<String> {
    let app_values = entitlements.get_array_strings(key).unwrap_or_default();
    let profile_values = profile.get_array_strings(key).unwrap_or_default();

    app_values
        .into_iter()
        .filter(|value| !profile_values.contains(value))
        .collect()
}