Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/docker-hub-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ jobs:

- name: Build the Docker image

run: docker build . --file Dockerfile -t mobilesecurity/mdast_cli:2026.03.02 -t mobilesecurity/mdast_cli:latest
run: docker build . --file Dockerfile -t mobilesecurity/mdast_cli:2026.6.1 -t mobilesecurity/mdast_cli:latest


- name: Docker Hub push latest image
run: docker push mobilesecurity/mdast_cli:latest

- name: Docker Hub push tagged image

run: docker push mobilesecurity/mdast_cli:2026.03.02
run: docker push mobilesecurity/mdast_cli:2026.6.1



Expand Down
39 changes: 32 additions & 7 deletions mdast_cli/distribution_systems/appstore_client/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,28 @@

# Apple "bag" service: returns endpoint definitions (auth URL). Required since ~2025.
BAG_URL_TEMPLATE = "https://init.itunes.apple.com/bag.xml?guid=%s"
DEFAULT_AUTH_URL_TEMPLATE = "https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticate?guid=%s"
# Apple's 26HOTFIX24 (June 2026) moved login to the native auth endpoint. The bag now
# advertises ".../auth/v1/native" (no "/fast"); the endpoint only works WITH the "/fast/"
# sub-path AND a trailing slash, otherwise Apple replies 301 + an HTML redirect page that
# breaks the plist parser. See majd/ipatool#486.
AUTH_HOST = "auth.itunes.apple.com"
NATIVE_FAST_PATH = "/auth/v1/native/fast/"
DEFAULT_AUTH_URL = "https://" + AUTH_HOST + NATIVE_FAST_PATH
BUY_DOMAIN = "buy.itunes.apple.com"


def _normalize_auth_endpoint(endpoint: Optional[str]) -> Optional[str]:
"""Ensure the native auth endpoint carries the '/fast/' sub-path with a trailing slash.

Apple's bag advertises ".../auth/v1/native"; without "/fast/" the request gets a
301 + HTML redirect that the plist parser chokes on (majd/ipatool#486).
"""
if endpoint and AUTH_HOST in endpoint:
if not (endpoint.endswith("/fast") or endpoint.endswith("/fast/")):
endpoint = endpoint.rstrip("/") + "/fast"
if not endpoint.endswith("/"):
endpoint = endpoint + "/"
return endpoint
PURCHASE_PATH = "/WebObjects/MZBuy.woa/wa/buyProduct"
DOWNLOAD_PATH = "/WebObjects/MZFinance.woa/wa/volumeStoreDownloadProduct"

Expand Down Expand Up @@ -45,9 +65,14 @@ def _parse_bag_response(content: bytes) -> Optional[str]:
try:
data = plistlib.loads(content)
if isinstance(data, dict):
url_bag = data.get("urlBag") or data.get("URLBag")
if isinstance(url_bag, dict):
return url_bag.get("authenticateAccount") or url_bag.get("authenticate")
# 'authenticateAccount' moved to the bag root (majd/ipatool#486); older bags
# keep it under 'urlBag'. Check the root first, then fall back to urlBag.
endpoint = data.get("authenticateAccount") or data.get("authenticate")
if not endpoint:
url_bag = data.get("urlBag") or data.get("URLBag")
if isinstance(url_bag, dict):
endpoint = url_bag.get("authenticateAccount") or url_bag.get("authenticate")
return endpoint
return None
except Exception:
pass
Expand Down Expand Up @@ -121,13 +146,13 @@ def get_bag(self) -> str:
"Bag request failed: status=%s, falling back to default auth URL",
r.status_code,
)
return DEFAULT_AUTH_URL_TEMPLATE % self.guid
auth_endpoint = _parse_bag_response(r.content)
return DEFAULT_AUTH_URL
auth_endpoint = _normalize_auth_endpoint(_parse_bag_response(r.content))
if auth_endpoint:
logger.debug("Using auth endpoint from bag: %s", auth_endpoint[:60] + "...")
return auth_endpoint
logger.warning("Could not parse bag response, falling back to default auth URL")
return DEFAULT_AUTH_URL_TEMPLATE % self.guid
return DEFAULT_AUTH_URL

def authenticate(self, appleId, password):
if not self.guid:
Expand Down
25 changes: 22 additions & 3 deletions mdast_cli/distribution_systems/google_play_apkeep.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ async def download_app(
if proc.returncode != 0:
raise RuntimeError(sanitized_output_text.strip() or 'apkeep returned non-zero exit code')

if f'{package_name} downloaded successfully!' not in output:
success_marker_found = f'{package_name} downloaded successfully!' in output
if not success_marker_found:
logger.warning(f'Google Play - success marker not found in output, continuing artifact check (package: {package_name})')

split_dir = os.path.join(download_dir, package_name)
Expand Down Expand Up @@ -279,8 +280,26 @@ async def download_app(
except Exception as ex:
logger.debug(f'Google Play - failed to scan directory for candidates: {ex} (package: {package_name})')

logger.error(f'Google Play - artifact not found after successful download (package: {package_name})')
raise RuntimeError('Google Play: download reports success but artifact not found')
if not success_marker_found:
# apkeep exited without printing the "downloaded successfully!" marker and produced no file.
# This is how Google Play behaves when it refuses to deliver the APK for the requested
# device profile: most commonly the app has no native build for the device's CPU ABI
# (e.g. requesting an x86_64 emulator profile for an arm64-only app), but it can also mean
# the app is unavailable for the account's region or is not acquired in its library.
# Not a real download failure on our side.
logger.error(f'Google Play - apkeep produced no success marker and no artifact; '
f'Google Play did not deliver the app for the requested device profile '
f'(likely CPU ABI/architecture mismatch, e.g. no x86_64 build for an arm64-only app; '
f'or region/availability/acquisition) (package: {package_name})')
raise RuntimeError(
'Google Play: apkeep finished without a success marker and produced no artifact - '
'Google Play did not deliver the app for the requested device profile. Most likely the app '
'has no native build for the requested CPU ABI (e.g. requesting x86_64 for an arm64-only app); '
'also check region/availability and that the app is acquired in the account library'
)

logger.error(f'Google Play - success marker present but artifact not found (package: {package_name})')
raise RuntimeError('Google Play: apkeep reported success but the artifact could not be located')


def _ensure_dir_exists(path: str) -> None:
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
setup(
name="mdast_cli",

version='2026.03.02',
version='2026.6.1',

python_requires='>=3.12',

Expand Down