diff --git a/src/launchpad/api/update_api_models.py b/src/launchpad/api/update_api_models.py index 46b0f100..c0085a2f 100644 --- a/src/launchpad/api/update_api_models.py +++ b/src/launchpad/api/update_api_models.py @@ -10,6 +10,8 @@ class AppleAppInfo(BaseModel): is_code_signature_valid: Optional[bool] = None code_signature_errors: Optional[List[str]] = None main_binary_uuid: Optional[str] = None + profile_expiration_date: Optional[str] = None + certificate_expiration_date: Optional[str] = None # TODO: add "date_built" field once exposed in 'AppleAppInfo' diff --git a/src/launchpad/service.py b/src/launchpad/service.py index 3baa7e87..96d31ad9 100644 --- a/src/launchpad/service.py +++ b/src/launchpad/service.py @@ -436,6 +436,8 @@ def _get_artifact_type(artifact: Artifact) -> ArtifactType: is_code_signature_valid=app_info.is_code_signature_valid, code_signature_errors=app_info.code_signature_errors, main_binary_uuid=app_info.main_binary_uuid, + profile_expiration_date=app_info.profile_expiration_date, + certificate_expiration_date=app_info.certificate_expiration_date, ) # TODO: add "date_built" and custom android fields diff --git a/src/launchpad/size/analyzers/apple.py b/src/launchpad/size/analyzers/apple.py index d2b975c0..7bfb6894 100644 --- a/src/launchpad/size/analyzers/apple.py +++ b/src/launchpad/size/analyzers/apple.py @@ -7,6 +7,8 @@ import lief +from cryptography import x509 + from launchpad.artifacts.apple.zipped_xcarchive import BinaryInfo, ZippedXCArchive from launchpad.artifacts.artifact import AppleArtifact from launchpad.parsers.apple.macho_parser import MachOParser @@ -213,8 +215,15 @@ def _extract_app_info(self, xcarchive: ZippedXCArchive) -> AppleAppInfo: provisioning_profile = xcarchive.get_provisioning_profile() codesigning_type = None profile_name = None + profile_expiration_date = None + certificate_expiration_date = None if provisioning_profile: codesigning_type, profile_name = self._get_profile_type(provisioning_profile) + expiration_date = provisioning_profile.get("ExpirationDate") + if expiration_date: + # Convert datetime to ISO format string + profile_expiration_date = expiration_date.isoformat() + certificate_expiration_date = self._extract_certificate_expiration_date(provisioning_profile) supported_platforms = plist.get("CFBundleSupportedPlatforms", []) is_simulator = "iphonesimulator" in supported_platforms or plist.get("DTPlatformName") == "iphonesimulator" @@ -241,6 +250,8 @@ def _extract_app_info(self, xcarchive: ZippedXCArchive) -> AppleAppInfo: is_simulator=is_simulator, codesigning_type=codesigning_type, profile_name=profile_name, + profile_expiration_date=profile_expiration_date, + certificate_expiration_date=certificate_expiration_date, is_code_signature_valid=is_code_signature_valid, code_signature_errors=code_signature_errors, main_binary_uuid=xcarchive.get_main_binary_uuid(), @@ -282,6 +293,40 @@ def _get_profile_type(self, profile_data: dict[str, Any]) -> Tuple[str, str]: # If no devices are provisioned, it's an app store profile return "appstore", profile_name + def _extract_certificate_expiration_date(self, provisioning_profile: dict[str, Any]) -> str | None: + """Extract the earliest expiration date from developer certificates. + + Args: + provisioning_profile: Dictionary containing the mobileprovision contents + + Returns: + ISO format string of the earliest certificate expiration date, or None if no certificates found + """ + developer_certs = provisioning_profile.get("DeveloperCertificates", []) + if not developer_certs: + return None + + earliest_expiration = None + + for cert_data in developer_certs: + try: + # Parse DER certificate + cert = x509.load_der_x509_certificate(cert_data) + expiration_date = cert.not_valid_after_utc + + # Track the earliest expiration date + if earliest_expiration is None or expiration_date < earliest_expiration: + earliest_expiration = expiration_date + + except Exception as e: + logger.error(f"Failed to parse certificate: {e}") + continue + + if earliest_expiration: + return earliest_expiration.isoformat() + + return None + def _generate_insight_with_tracing( self, insight_class: type, insights_input: InsightsInput, insight_name: str ) -> Any: diff --git a/src/launchpad/size/models/apple.py b/src/launchpad/size/models/apple.py index 305d1764..ce46f176 100644 --- a/src/launchpad/size/models/apple.py +++ b/src/launchpad/size/models/apple.py @@ -153,6 +153,8 @@ class AppleAppInfo(BaseAppInfo): None, description="Type of codesigning used (development, adhoc, appstore, enterprise)" ) profile_name: str | None = Field(None, description="Name of the provisioning profile used") + profile_expiration_date: str | None = Field(None, description="Expiration date of the provisioning profile") + certificate_expiration_date: str | None = Field(None, description="Expiration date of the developer certificate") is_code_signature_valid: bool = Field(True, description="Whether the app's code signature is valid") code_signature_errors: List[str] = Field( default_factory=list, description="List of code signature validation errors" diff --git a/tests/unit/test_apple_basic_info.py b/tests/unit/test_apple_basic_info.py index 1127ee6c..190f75cc 100644 --- a/tests/unit/test_apple_basic_info.py +++ b/tests/unit/test_apple_basic_info.py @@ -26,3 +26,7 @@ def test_basic_info(self) -> None: assert basic_info.is_code_signature_valid is True assert basic_info.code_signature_errors == [] assert basic_info.main_binary_uuid == "BEB3C0D6-2518-343D-BB6F-FF5581C544E8" + assert basic_info.profile_expiration_date is not None + assert basic_info.profile_expiration_date == "2025-12-02T18:15:00" + assert basic_info.certificate_expiration_date is not None + assert basic_info.certificate_expiration_date == "2025-01-01T17:56:11+00:00"