verifyos-cli 0.13.1

AI agent-friendly Rust CLI for scanning iOS app bundles for App Store rejection risks before submission.
Documentation
use crate::parsers::plist_reader::InfoPlist;
use crate::rules::core::{
    AppStoreRule, ArtifactContext, RuleCategory, RuleError, RuleReport, RuleStatus, Severity,
};
use crate::rules::entitlements::{APS_ENVIRONMENT_KEY, EXTENSION_SUBSET_ENTITLEMENT_KEYS};

pub struct ExtensionEntitlementsCompatibilityRule;

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

    fn name(&self) -> &'static str {
        "Extension Entitlements Compatibility"
    }

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

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

    fn recommendation(&self) -> &'static str {
        "Ensure extension entitlements are a subset of the host app and required keys exist for the extension type."
    }

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

        let extensions: Vec<_> = bundles
            .into_iter()
            .filter(|bundle| {
                bundle
                    .bundle_path
                    .extension()
                    .and_then(|e| e.to_str())
                    .map(|ext| ext == "appex")
                    .unwrap_or(false)
            })
            .collect();

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

        let Some(app_entitlements) = artifact.entitlements_for_bundle(artifact.app_bundle_path)?
        else {
            return Ok(RuleReport {
                status: RuleStatus::Skip,
                message: Some("Host app entitlements not found".to_string()),
                evidence: None,
            });
        };

        let mut issues = Vec::new();

        for extension in extensions {
            let plist = match artifact.bundle_info_plist(&extension.bundle_path) {
                Ok(Some(plist)) => plist,
                Ok(None) | Err(_) => continue,
            };

            let extension_point = plist
                .get_dictionary("NSExtension")
                .and_then(|dict| dict.get("NSExtensionPointIdentifier"))
                .and_then(|value| value.as_string())
                .unwrap_or("unknown")
                .to_string();

            let Some(ext_entitlements) =
                artifact.entitlements_for_bundle(&extension.bundle_path)?
            else {
                continue;
            };

            let subset_issues = compare_entitlements(&app_entitlements, &ext_entitlements);
            for issue in subset_issues {
                issues.push(format!("{}: {}", extension.display_name, issue));
            }

            let required = required_entitlements_for_extension(&extension_point);
            for requirement in required {
                if !ext_entitlements.has_key(requirement) {
                    issues.push(format!(
                        "{}: missing entitlement {} for {}",
                        extension.display_name, requirement, extension_point
                    ));
                    continue;
                }
                if *requirement == "com.apple.security.application-groups" {
                    let groups = ext_entitlements
                        .get_array_strings(requirement)
                        .unwrap_or_default();
                    if groups.is_empty() {
                        issues.push(format!(
                            "{}: empty entitlement {} for {}",
                            extension.display_name, requirement, extension_point
                        ));
                    }
                }
            }
        }

        if issues.is_empty() {
            return Ok(RuleReport {
                status: RuleStatus::Pass,
                message: Some("Extension entitlements are compatible".to_string()),
                evidence: None,
            });
        }

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

fn compare_entitlements(app: &InfoPlist, ext: &InfoPlist) -> Vec<String> {
    let mut issues = Vec::new();

    for key in EXTENSION_SUBSET_ENTITLEMENT_KEYS {
        if !ext.has_key(key) {
            continue;
        }

        if !app.has_key(key) {
            issues.push(format!("entitlement {key} not present in host app"));
            continue;
        }

        if let Some(values) = ext.get_array_strings(key) {
            let app_values = app.get_array_strings(key).unwrap_or_default();
            let missing: Vec<String> = values
                .into_iter()
                .filter(|value| !app_values.iter().any(|app_value| app_value == value))
                .collect();
            if !missing.is_empty() {
                issues.push(format!(
                    "entitlement {key} values not in host app: {}",
                    missing.join(", ")
                ));
            }
            continue;
        }

        if let Some(value) = ext.get_string(key) {
            if app.get_string(key) != Some(value) {
                issues.push(format!("entitlement {key} mismatch"));
            }
            continue;
        }

        if let Some(value) = ext.get_bool(key) {
            if app.get_bool(key) != Some(value) {
                issues.push(format!("entitlement {key} mismatch"));
            }
        }
    }

    issues
}

fn required_entitlements_for_extension(extension_point: &str) -> &'static [&'static str] {
    match extension_point {
        "com.apple.widgetkit-extension" => &["com.apple.security.application-groups"],
        "com.apple.usernotifications.service" => &[APS_ENVIRONMENT_KEY],
        _ => &[],
    }
}

#[cfg(test)]
mod tests {
    use super::compare_entitlements;
    use crate::parsers::plist_reader::InfoPlist;
    use crate::rules::entitlements::{
        ICLOUD_CONTAINER_IDENTIFIERS_KEY, KEYCHAIN_ACCESS_GROUPS_KEY,
    };
    use plist::{Dictionary, Value};

    #[test]
    fn compare_entitlements_uses_shared_subset_keys() {
        let mut app = Dictionary::new();
        app.insert(
            KEYCHAIN_ACCESS_GROUPS_KEY.to_string(),
            Value::Array(vec![Value::String("group.shared".to_string())]),
        );
        app.insert(
            ICLOUD_CONTAINER_IDENTIFIERS_KEY.to_string(),
            Value::Array(vec![Value::String("iCloud.shared".to_string())]),
        );

        let mut ext = Dictionary::new();
        ext.insert(
            KEYCHAIN_ACCESS_GROUPS_KEY.to_string(),
            Value::Array(vec![
                Value::String("group.shared".to_string()),
                Value::String("group.extra".to_string()),
            ]),
        );

        let issues = compare_entitlements(
            &InfoPlist::from_dictionary(app),
            &InfoPlist::from_dictionary(ext),
        );

        assert_eq!(
            issues,
            vec!["entitlement keychain-access-groups values not in host app: group.extra"]
        );
    }
}