From b5769c867e07cd622bed562b70ee2d106a479f2a Mon Sep 17 00:00:00 2001 From: Nikita Pinaev Date: Mon, 5 Oct 2026 01:04:44 +0300 Subject: [PATCH 1/6] STG-5356: correct App Store credentials and bounded signed auth retries --- README.md | 13 +- docs/STG-5356-appstore-download.md | 28 ++++ .../appstore_client/store.py | 74 ++++++++-- mdast_cli/mdast_scan.py | 10 +- specs/appstore-download-20261005/spec.md | 28 ++++ tests/test_appstore_auth.py | 139 ++++++++++++++++++ 6 files changed, 267 insertions(+), 25 deletions(-) create mode 100644 docs/STG-5356-appstore-download.md create mode 100644 specs/appstore-download-20261005/spec.md diff --git a/README.md b/README.md index f8cd442..9f58192 100644 --- a/README.md +++ b/README.md @@ -404,9 +404,9 @@ Download iOS applications (.ipa) from the App Store. - `--appstore_bundle_id ` - Bundle identifier - `--appstore_apple_id ` - iTunes account email - `--appstore_password ` - iTunes account password -- `--appstore_2FA ` - 6-digit 2FA code **Optional Parameters:** +- `--appstore_2FA ` - Fresh 6-digit code when Apple requires verification - `--appstore_file_name ` - Custom filename for downloaded IPA **Getting App ID:** @@ -418,10 +418,10 @@ Download iOS applications (.ipa) from the App Store. 1. Run the script with email and password 2. You'll receive a 2FA code on your device 3. Use the code with `--appstore_2FA` -4. **Save the combined password+2FA** format for 6 months: `password2FA` (e.g., `P@ssword742877`) +4. A successful login is cached in `appstore_sessions` under the current working directory. Reuse that session from the same directory; an old one-time code is not a reusable credential. **Deprecated Parameter:** -- `--appstore_password2FA` - Will be removed on 01.05.2023. Use separate `--appstore_password` and `--appstore_2FA` instead. +- `--appstore_password2FA` - Retained for compatibility. Prefer separate `--appstore_password` and optional `--appstore_2FA`. **Example:** ```bash @@ -441,8 +441,9 @@ mdast_cli \ **Troubleshooting:** - **"Wrong Apple ID" error**: Contact support to coordinate the Apple ID for AppStore integration -- **Session expired**: Re-authenticate and save the new 2FA code +- **Session expired**: Re-authenticate with a fresh code if Apple requests it - **Login errors**: Ensure 2FA is enabled and code is current (6-digit format) +- **HTTP 204/403/404/429/5xx without an account response**: The CLI retries transient responses at most three times with backoff and respects `Retry-After`. A malformed redirect or an exhausted retry budget remains a download failure; these statuses alone do not establish that the password is incorrect. **Note:** This integration uses [ipatool](https://github.com/majd/ipatool) - thanks to all contributors! @@ -976,9 +977,9 @@ mdast_cli -d \ **Solutions:** 1. Ensure 2FA is enabled on the Apple ID 2. Use the 6-digit 2FA code (not the longer backup code) -3. Format password+2FA correctly: `password2FA` (e.g., `P@ssword742877`) +3. Pass the password via `--appstore_password` and a fresh code via `--appstore_2FA` when requested 4. Contact support if Apple ID needs to be whitelisted -5. Re-authenticate if session expired (sessions last ~6 months) +5. Re-authenticate if the saved session expires; its lifetime is controlled by Apple #### Firebase Service Account Errors diff --git a/docs/STG-5356-appstore-download.md b/docs/STG-5356-appstore-download.md new file mode 100644 index 0000000..27f4108 --- /dev/null +++ b/docs/STG-5356-appstore-download.md @@ -0,0 +1,28 @@ +# STG-5356: App Store через обычный интерфейс CLI + +## Исправленные дефекты + +CLI передавала `None` вместо пароля при пустом `--appstore_2FA`. Отсутствующий код отклонялся ещё при разборе аргументов, даже когда есть сохранённая сессия. Теперь отдельный пароль передаётся без потери, код добавляется при наличии, пробелы в коде убираются. Старый `--appstore_password2FA` совместим. Если Apple возвращает `MZFinance.BadLogin.Configurator_message`, CLI предлагает свежий код и завершает вход. + +Временные ответы без account plist повторяются максимум три раза на том же проверенном auth URL. Паузы 10/20 секунд, `Retry-After` имеет приоритет; ожидание больше 30 секунд завершает запрос без преждевременного повтора. HTTP 204/404/429/5xx, HTML 403 и наблюдаемый HTML 301 без Location учитываются отдельно от ошибок аккаунта. Подпись заново вычисляется для неизменённого тела и protocol attempt. Сертификатные ошибки не повторяются. + +Относительный Location сначала разрешается относительно проверенного URL, затем проходит прежний allowlist. Некорректный присутствующий Location не повторяется, credentials на него не отправляются. Для HTML 301 без Location разрешён только повтор исходного адреса, без догадки о новом endpoint и без unsigned fallback. + +Классификация временных отказов и backoff сверены с текущим [upstream](https://github.com/majd/ipatool/blob/main/pkg/appstore/appstore_login.go). Подобный HTML 301 описан в [issue 519](https://github.com/majd/ipatool/issues/519); upstream v2.5.0 использует короткие паузы 0,25/0,5 секунды и не содержит всех правок текущего main. + +## Проверки 05.10.2026 + +- Полный набор: 354 теста успешны. Отдельный security gate: 20 тестов успешны. +- Добавлены проверки передачи credentials через обычные аргументы, bounded retries, backoff, Retry-After (число/дата/слишком долго), точной подписи, остановки на ошибках аккаунта/TLS, безопасного относительного redirect и отсутствия утечек. +- Изолированный процесс существующего dev-контейнера выполняет `mdast_scan.main()` с кандидатными модулями в памяти. Установленная CLI, БД, конфигурация и общие сессии не меняются. +- Последний штатный путь кандидата: основной Apple host вернул 302 на Store pod, затем pod вернул 204/404/204. CLI завершилась DOWNLOAD_FAILED, IPA нет. Повтор из сохранённой сессии не выполнен, потому что вход не завершился. +- Диагностическая идентичность устройства через MAC вместо GUID аккаунта получила HTML 301 без Location. Менять GUID в продуктовой реализации оснований нет. +- Независимый контроль Go net/http с тем же опубликованным Linux signer также получил 204/403, пригодного account plist нет. Замена requests на Go не доказана как исправление. + +## Статус приёмки + +Исправлены воспроизводимые дефекты CLI, но успешный свежий вход и IPA пока не получены. Это кандидат для review, а не готовый релиз. Не создавать релизный тег, не публиковать пакет и не доставлять его на стенды до выполнения end-to-end критериев спецификации. + +Внутренняя причина отказа Apple не установлена. HTTP-статусы сами по себе не доказывают неверные credentials. Последний OTP не переиспользуется. Локальный native ARM64 signer отдельно ограничен sandbox/sysctl; это не тот же отказ, что Linux HTTP-ответы. + +Спецификация: [spec.md](../specs/appstore-download-20261005/spec.md). Задача: [STG-5356](https://tracker.yandex.ru/STG-5356). diff --git a/mdast_cli/distribution_systems/appstore_client/store.py b/mdast_cli/distribution_systems/appstore_client/store.py index bf5f7ff..754ffa8 100755 --- a/mdast_cli/distribution_systems/appstore_client/store.py +++ b/mdast_cli/distribution_systems/appstore_client/store.py @@ -4,6 +4,8 @@ import plistlib import re import time +from email.utils import parsedate_to_datetime +from urllib.parse import urljoin import requests from requests.adapters import HTTPAdapter from mdast_cli.distribution_systems.appstore_client.sap import SAPError, SAPSigner, parse_bag, validate_endpoint @@ -13,6 +15,9 @@ BAG_URL_TEMPLATE = "https://init.itunes.apple.com/bag.xml?guid=%s" LEGACY_AUTH_URL = "https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticate" AUTH_MAX_REDIRECTS = 4 +AUTH_MAX_REQUEST_ATTEMPTS = 3 +AUTH_RETRY_DELAY = 10 +AUTH_MAX_RETRY_DELAY = 30 BUY_DOMAIN = "buy.itunes.apple.com" @@ -143,31 +148,71 @@ def _post_authenticate(self, url, appleId, password, attempt, signer): data=body, allow_redirects=False, verify=True, timeout=60, ) + def _auth_retry_delay(self, response, retry): + delay = min(AUTH_RETRY_DELAY * 2 ** retry, AUTH_MAX_RETRY_DELAY) + value = response.headers.get('Retry-After', '').strip() if response is not None else '' + if not value: + return delay + try: + requested = int(value) if value.isdigit() else parsedate_to_datetime(value).timestamp() - time.time() + except (ValueError, TypeError, OverflowError): + return delay + if requested > AUTH_MAX_RETRY_DELAY: + raise StoreException('authenticate', 'Apple requested a longer wait; try again later') + return max(1, requested) + + def _auth_request(self, url, appleId, password, attempt, signer): + outcomes = [] + for retry in range(AUTH_MAX_REQUEST_ATTEMPTS): + response = None + try: + response = self._post_authenticate(url, appleId, password, attempt, signer) + except requests.exceptions.SSLError: + raise StoreException('authenticate', 'Apple TLS verification failed') from None + except (requests.exceptions.Timeout, requests.exceptions.ConnectionError): + outcomes.append('transport error') + else: + status = response.status_code + logger.info('App Store signed authentication response: HTTP %s', status) + try: + data = plistlib.loads(response.content) + if not isinstance(data, dict): + raise ValueError + except (ValueError, TypeError, plistlib.InvalidFileException): + data = None + # Populated Apple account responses and redirects are protocol + # outcomes, never network retries. Re-sign exactly the same body + # for each transient request without changing protocol attempt. + html = ('text/html' in response.headers.get('Content-Type', '').lower() + or response.content.lstrip().lower().startswith((b'= 500 + or (status == 403 and html) + or (status == 301 and html and not response.headers.get('Location'))) + if data is not None or not transient: + return response, data + outcomes.append('HTTP %s' % status) + if retry + 1 == AUTH_MAX_REQUEST_ATTEMPTS: + raise StoreException('authenticate', 'Apple authentication failed after %s requests (%s)' % ( + AUTH_MAX_REQUEST_ATTEMPTS, ', '.join(outcomes))) from None + time.sleep(self._auth_retry_delay(response, retry)) + def _authenticate_at(self, auth_url, appleId, password, signer): - url, attempt, redirects, transient = auth_url, 1, 0, 0 + url, attempt, redirects = auth_url, 1, 0 while True: - response = self._post_authenticate(url, appleId, password, attempt, signer) + response, data = self._auth_request(url, appleId, password, attempt, signer) status = response.status_code - logger.info("App Store signed authentication response: HTTP %s", status) if status in (301, 302, 303, 307, 308): location = response.headers.get('Location') - if not location: + if not location or not location.strip(): raise StoreException('authenticate', 'Apple redirect has no Location (HTTP %s)' % status) + location = urljoin(url, location.strip()) validate_endpoint(location, authentication=True) redirects += 1 if redirects > AUTH_MAX_REDIRECTS: raise StoreException('authenticate', 'Too many Apple authentication redirects') url = location continue - try: - data = plistlib.loads(response.content) - if not isinstance(data, dict): - raise ValueError - except (ValueError, TypeError, plistlib.InvalidFileException): - transient += 1 - if (status in (204, 404) or status >= 500) and transient < 3: - time.sleep(0.25 * transient) - continue + if data is None: raise StoreException('authenticate', 'Apple returned a non-plist authentication response (HTTP %s)' % status) from None resp = StoreAuthenticateResp.from_dict(data) # Both response shapes are in use: older clients read download-queue-info. @@ -178,8 +223,9 @@ def _authenticate_at(self, auth_url, appleId, password, signer): return response, resp if attempt == 1 and str(resp.failureType) == '-5000': attempt = 2 - transient = 0 continue + if not resp.failureType and resp.customerMessage == 'MZFinance.BadLogin.Configurator_message': + raise StoreException('authenticate', 'Apple requires verification; provide a fresh --appstore_2FA code (MZFinance.BadLogin.Configurator_message)') raise StoreException('authenticate', resp.customerMessage or 'Apple rejected authentication', resp.failureType) def authenticate(self, appleId, password): diff --git a/mdast_cli/mdast_scan.py b/mdast_cli/mdast_scan.py index b301635..0a3e8de 100644 --- a/mdast_cli/mdast_scan.py +++ b/mdast_cli/mdast_scan.py @@ -176,7 +176,7 @@ def parse_args(): 'It is recommended to use environment variables for security.') appstore_group.add_argument('--appstore_2FA', type=str, help='Two-factor authentication code (6 digits). ' - 'Required parameter when --distribution_system is set to "appstore". ' + 'Optional; provide a fresh code if Apple requests verification. ' 'Code is sent to trusted Apple devices. ' 'Example: 123456') appstore_group.add_argument('--appstore_file_name', type=str, @@ -445,9 +445,9 @@ def parse_args(): elif args.distribution_system == 'appstore' and ( (args.appstore_app_id is None and args.appstore_bundle_id is None) or args.appstore_apple_id is None or - (args.appstore_password is None or args.appstore_2FA is None) and args.appstore_password2FA is None): + args.appstore_password is None and args.appstore_password2FA is None): parser.error('"--distribution_system appstore" requires either "--appstore_app_id" or "--appstore_bundle_id", ' - '"--appstore_apple_id" and ("--appstore_password" + "--appstore_2FA")/' + '"--appstore_apple_id" and "--appstore_password" (optional "--appstore_2FA")/' '(deprecated "--appstore_password2FA") arguments to be set') elif args.distribution_system == 'google_play': @@ -843,8 +843,8 @@ def main(): arguments.firebase_file_extension) elif distribution_system == 'appstore': - if arguments.appstore_password and arguments.appstore_2FA: - password2FA = arguments.appstore_password + arguments.appstore_2FA + if arguments.appstore_password is not None: + password2FA = arguments.appstore_password + (arguments.appstore_2FA or '').replace(' ', '') else: password2FA = arguments.appstore_password2FA appstore = AppStore(arguments.appstore_apple_id, diff --git a/specs/appstore-download-20261005/spec.md b/specs/appstore-download-20261005/spec.md new file mode 100644 index 0000000..359f54c --- /dev/null +++ b/specs/appstore-download-20261005/spec.md @@ -0,0 +1,28 @@ +# STG-5356: восстановить скачивание App Store через mdast_cli + +## Проблема и цель + +Пользователь попросил восстановить получение IPA именно через обычный интерфейс mdast_cli. Свежая авторизация сейчас завершается до запроса приложения: HTTP 204/404/403 либо redirect без Location. Старые успешные загрузки подтверждены; конкретная внутренняя причина текущих отказов Apple пока не установлена. На локальном ARM64 SAP helper дополнительно падает в Unicorn при запрещённом sandbox-запросе sysctl. Это отдельное ограничение среды проверки. + +## Границы + +Только App Store-клиент CLI и необходимые воспроизводимые проверки. Google Play, crypto-зависимости, API стендов, общие сессии, БД, инфраструктура и конфигурация prod/dev не меняются. Не отключать проверку TLS, не отправлять credentials на неподтверждённый endpoint, не использовать unsigned auth или устаревший одноразовый код. Не публиковать исправление как работающее без фактической IPA. + +## План + +1. Сравнить подписанный запрос CLI с официальным ipatool, включая идентичность устройства, транспорт, заголовки и тело. Контроли выполнять в изолированном временном каталоге. +2. Проверить ограниченную обработку временных отказов по актуальному upstream: задержки, Retry-After, HTML 403/429, transport failures. Настоящие ошибки credentials и небезопасные redirect не повторять. + Проверить передачу credentials через CLI: пустой или отсутствующий 2FA не должен превращать непустой пароль в None. Пароль без кода допустим для начала входа и использования сохранённой сессии; если Apple требует код, завершить вход с понятным сообщением. + Наблюдаемый HTML 301 без Location допускает только ограниченный повтор на том же уже проверенном auth URL. Это не redirect: новый адрес не подставляется. Любой присутствующий некорректный Location отклоняется сразу. +3. Реализовать только подтверждённые изменения; добавить проверки количества повторов, точной подписи, redirect allowlist, остановки при credential errors и защиты секретов. +4. Проверить штатный вход и загрузку через CLI, затем повтор из другого процесса с сохранённой сессией. Не считать диагностическую подмену транспорта или удалённый signer штатной проверкой локальной версии. +5. Прогнать существующие регрессионные проверки, собрать пакет и подготовить PR только с честным результатом приёмки. + +## Критерии приёмки + +- Из чистой сессии CLI получает валидную IPA исходного запрошенного приложения и нескольких других бесплатных приложений. ZIP CRC, Payload/Info.plist и исполняемый файл проверены. +- Второй процесс использует сохранённую сессию и повторно скачивает IPA без нового auth POST. +- Повторы ограничены; Retry-After соблюдается либо запрос завершается, если ожидание превышает бюджет. Отказы аккаунта не маскируются под временную сетевую ошибку. +- Нет отправки пароля на чужой host, TLS обхода, утечек credentials/подписи и регрессии Google Play. +- Пароль корректно передаётся через обычные аргументы с кодом, без кода и с пустым кодом; старый аргумент password2FA остаётся совместимым. +- Недоступность локального native signer в sandbox отмечается отдельно. Пока настоящий end-to-end результат не подтверждён, публикация и доставка не выполняются. diff --git a/tests/test_appstore_auth.py b/tests/test_appstore_auth.py index deb26f3..6bf86e6 100644 --- a/tests/test_appstore_auth.py +++ b/tests/test_appstore_auth.py @@ -3,6 +3,7 @@ import plistlib import pytest +import requests from mdast_cli.distribution_systems.appstore_client import store as store_mod from mdast_cli.distribution_systems.appstore_client.store import LEGACY_AUTH_URL, StoreClient, StoreException @@ -120,6 +121,144 @@ def test_transient_response_budget_is_three(): assert len(client.sess.calls) == 3 +def test_html_forbidden_is_retried_with_backoff(monkeypatch): + waits = [] + monkeypatch.setattr(store_mod.time, 'sleep', waits.append) + client = _client([ + FakeResponse(403, b'Forbidden', {'Content-Type': 'text/html'}), + FakeResponse(404), FakeResponse(200, plistlib.dumps(SUCCESS_PLIST)), + ]) + client.authenticate('user@example.com', 'secret') + assert waits == [10, 20] + assert len(set(FakeSigner.last.payloads)) == 1 + assert [c['body']['attempt'] for c in client.sess.calls] == ['1'] * 3 + + +def test_html_301_without_location_retries_only_original_endpoint(): + client = _client([FakeResponse(301, b'Moved', {'Content-Type': 'text/html'}), + FakeResponse(302, headers={'Location': POD_URL}), + FakeResponse(200, plistlib.dumps(SUCCESS_PLIST))]) + client.authenticate('user@example.com', 'secret') + assert [c['url'] for c in client.sess.calls] == [LEGACY_AUTH_URL, LEGACY_AUTH_URL, POD_URL] + assert len(set(FakeSigner.last.payloads)) == 1 + + +def test_html_301_without_location_remains_bounded(): + client = _client([FakeResponse(301, b'Moved') for _ in range(3)]) + with pytest.raises(StoreException, match='3 requests'): + client.authenticate('user@example.com', 'secret') + assert len(client.sess.calls) == 3 + + +def test_html_301_with_unsafe_location_fails_without_retry(): + client = _client([FakeResponse(301, b'Moved', {'Location': 'https://evil.example/'})]) + with pytest.raises(StoreException, match='endpoint'): + client.authenticate('user@example.com', 'secret') + assert len(client.sess.calls) == 1 + + +@pytest.mark.parametrize('status', [403, 429, 503]) +def test_populated_account_refusal_is_not_retried(status): + failure = {'failureType': '1234', 'customerMessage': 'Incorrect password'} + client = _client([FakeResponse(status, plistlib.dumps(failure))]) + with pytest.raises(StoreException, match='Incorrect password'): + client.authenticate('user@example.com', 'secret') + assert len(client.sess.calls) == 1 + + +def test_retry_after_takes_precedence(monkeypatch): + waits = [] + monkeypatch.setattr(store_mod.time, 'sleep', waits.append) + client = _client([FakeResponse(429, headers={'Retry-After': '15'}), + FakeResponse(200, plistlib.dumps(SUCCESS_PLIST))]) + client.authenticate('user@example.com', 'secret') + assert waits == [15] + + +def test_retry_after_date_is_respected(monkeypatch): + from email.utils import formatdate + monkeypatch.setattr(store_mod.time, 'time', lambda: 1000) + waits = [] + monkeypatch.setattr(store_mod.time, 'sleep', waits.append) + client = _client([FakeResponse(429, headers={'Retry-After': formatdate(1025, usegmt=True)}), + FakeResponse(200, plistlib.dumps(SUCCESS_PLIST))]) + client.authenticate('user@example.com', 'secret') + assert waits == [25] + + +@pytest.mark.parametrize('value', ['31', '99999999999999999999999999999999999']) +def test_long_retry_after_stops_without_early_retry(value): + client = _client([FakeResponse(429, headers={'Retry-After': value})]) + with pytest.raises(StoreException, match='longer wait'): + client.authenticate('user@example.com', 'secret') + assert len(client.sess.calls) == 1 + + +@pytest.mark.parametrize('error', [requests.exceptions.Timeout, requests.exceptions.ConnectionError]) +def test_transport_retry_is_bounded_and_sanitized(monkeypatch, error): + calls = [] + def failed(*args, **kwargs): + calls.append(args) + raise error('private password signature') + client = _client([]) + monkeypatch.setattr(client.sess, 'post', failed) + with pytest.raises(StoreException, match='3 requests') as result: + client.authenticate('user@example.com', 'secret') + assert 'private' not in str(result.value) + assert len(calls) == 3 + assert FakeSigner.last.closed + + +def test_certificate_error_is_not_retried_or_exposed(monkeypatch): + calls = [] + def failed(*args, **kwargs): + calls.append(args) + raise requests.exceptions.SSLError('private request') + client = _client([]) + monkeypatch.setattr(client.sess, 'post', failed) + with pytest.raises(StoreException, match='TLS verification') as result: + client.authenticate('user@example.com', 'secret') + assert 'private' not in str(result.value) + assert len(calls) == 1 + assert FakeSigner.last.closed + + +def test_relative_pod_redirect_is_validated(): + client = _client([FakeResponse(302, headers={'Location': '//p7-buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticate?Pod=7'}), + FakeResponse(200, plistlib.dumps(SUCCESS_PLIST))]) + client.authenticate('user@example.com', 'secret') + assert client.sess.calls[1]['url'] == POD_URL.split('&PRH=7')[0] + + +@pytest.mark.parametrize('location', [' ', '//evil.example/WebObjects/MZFinance.woa/wa/authenticate']) +def test_invalid_relative_redirect_never_receives_credentials(location): + client = _client([FakeResponse(302, headers={'Location': location})]) + with pytest.raises(StoreException): + client.authenticate('user@example.com', 'secret') + assert len(client.sess.calls) == 1 + + +@pytest.mark.parametrize('flags,password', [ + (['--appstore_password', 'secret'], 'secret'), + (['--appstore_password', 'secret', '--appstore_2FA', ''], 'secret'), + (['--appstore_password', 'secret', '--appstore_2FA', '123 456'], 'secret123456'), + (['--appstore_password2FA', 'secret123456'], 'secret123456'), +]) +def test_cli_passes_password_to_real_download_boundary(monkeypatch, tmp_path, flags, password): + from tests.conftest import run_main + captured = [] + def download(self, directory, *args): + captured.append(self.pass2FA) + artifact = tmp_path / 'application.ipa' + artifact.write_bytes(b'ipa download fixture') + return str(artifact), 'md5' + monkeypatch.setattr('mdast_cli.mdast_scan.AppStore.download_app', download) + assert run_main(monkeypatch, ['--distribution_system', 'appstore', '--download_only', + '--appstore_app_id', '1234', '--appstore_apple_id', 'user@example.com', + '--download_path', str(tmp_path)] + flags) == 0 + assert captured == [password] + + @pytest.mark.parametrize('status', [301, 302]) def test_redirect_without_location_is_rejected(status): client = _client([FakeResponse(status)]) From 3df154f041b817e718024ca2b48bd031b57646d9 Mon Sep 17 00:00:00 2001 From: Nikita Pinaev Date: Mon, 5 Oct 2026 01:26:07 +0300 Subject: [PATCH 2/6] STG-5356: support explicit local SAP cache and Rosetta helper --- MANIFEST.in | 2 + README.md | 7 ++++ docs/STG-5356-appstore-download.md | 10 ++++- .../appstore_client/sap.py | 15 +++++++ specs/appstore-download-20261005/spec.md | 10 +++++ tests/test_appstore_sap.py | 39 +++++++++++++++++++ tools/sap/build.py | 18 +++++++++ tools/sap/cache.go | 18 +++++++++ tools/sap/cache_test.go | 31 +++++++++++++++ 9 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 tools/sap/cache.go create mode 100644 tools/sap/cache_test.go diff --git a/MANIFEST.in b/MANIFEST.in index 68721df..7b438b1 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,6 @@ include tools/sap/main.go tools/sap/build.py tools/sap/upstream.json +include tools/sap/cache.go tools/sap/cache_test.go +include docs/STG-5356-appstore-download.md specs/appstore-download-20261005/spec.md include docs/STG-5076-appstore-sap.md recursive-include mdast_cli/distribution_systems/appstore_client/bin mdast-sap-* include mdast_cli/distribution_systems/appstore_client/IPATOOL-LICENSE diff --git a/README.md b/README.md index 9f58192..6e17d14 100644 --- a/README.md +++ b/README.md @@ -1186,6 +1186,13 @@ user cache directory is required. Alpine/musl is supported through its system loader. Offline first login is not supported; warm runtime assets avoid repeating these downloads. Account session caching continues to use `appstore_sessions`. +`MDAST_SAP_CACHE_DIR` optionally selects an absolute directory for these verified +public runtime assets. It does not change account-session storage or `HOME`. +On Apple Silicon, `MDAST_APPSTORE_SAP_ARCH=amd64` explicitly selects the bundled +Intel helper through installed Rosetta. The default remains the native helper; +no automatic architecture fallback occurs. This setting affects SAP signing +only, not the downloaded application's ABI or Google Play. + For development from a source checkout, install Go 1.27.1 and run `python tools/sap/build.py --target host`. Release builds use `--target all` before building the wheel. See `docs/STG-5076-appstore-sap.md` for the protocol, diff --git a/docs/STG-5356-appstore-download.md b/docs/STG-5356-appstore-download.md index 27f4108..fe203fe 100644 --- a/docs/STG-5356-appstore-download.md +++ b/docs/STG-5356-appstore-download.md @@ -12,7 +12,7 @@ CLI передавала `None` вместо пароля при пустом `- ## Проверки 05.10.2026 -- Полный набор: 354 теста успешны. Отдельный security gate: 20 тестов успешны. +- Полный набор после локальной настройки signer: 361 тест успешен. Отдельный security gate: 20 тестов успешны. - Добавлены проверки передачи credentials через обычные аргументы, bounded retries, backoff, Retry-After (число/дата/слишком долго), точной подписи, остановки на ошибках аккаунта/TLS, безопасного относительного redirect и отсутствия утечек. - Изолированный процесс существующего dev-контейнера выполняет `mdast_scan.main()` с кандидатными модулями в памяти. Установленная CLI, БД, конфигурация и общие сессии не меняются. - Последний штатный путь кандидата: основной Apple host вернул 302 на Store pod, затем pod вернул 204/404/204. CLI завершилась DOWNLOAD_FAILED, IPA нет. Повтор из сохранённой сессии не выполнен, потому что вход не завершился. @@ -26,3 +26,11 @@ CLI передавала `None` вместо пароля при пустом `- Внутренняя причина отказа Apple не установлена. HTTP-статусы сами по себе не доказывают неверные credentials. Последний OTP не переиспользуется. Локальный native ARM64 signer отдельно ограничен sandbox/sysctl; это не тот же отказ, что Linux HTTP-ответы. Спецификация: [spec.md](../specs/appstore-download-20261005/spec.md). Задача: [STG-5356](https://tracker.yandex.ru/STG-5356). + +## Локальный macOS signer + +Native ARM64 Unicorn в ограниченной среде падает при инициализации. Явный `MDAST_APPSTORE_SAP_ARCH=amd64` выбирает поставляемый Intel helper через существующую Rosetta. `MDAST_SAP_CACHE_DIR` задаёт абсолютный доступный каталог для проверенных public runtime assets, без изменения HOME. Account cache остаётся отдельным. Нет автоматического fallback, другой signer, изменения runtime dylib, TLS или sandbox. + +Штатный signer из установленного кандидатного wheel успешно открыл SAP-сессию и вернул подпись 501 байт за 4,59 секунды. Собраны все шесть helpers; optional cache hook проверен Go-тестами и включён в воспроизводимую сборку и sdist. Google Play DaOffice (44 901 269 байт, 4 APK) и Wikipedia (27 127 132 байта, 3 APK) успешно скачаны обычным CLI entrypoint, фактическая ABI x86_64, архивы проверены. Runtime crypto-зависимости не менялись. + +Это подтверждает исправленный локальный запуск signer, а не полную приёмку Apple: fresh login/IPA с новым 2FA ещё не проверены. Предоставленный ранее OTP устарел; запрошен свежий код для следующего контрольного входа. Неверный пароль не установлен. diff --git a/mdast_cli/distribution_systems/appstore_client/sap.py b/mdast_cli/distribution_systems/appstore_client/sap.py index 39b8a37..07582ed 100644 --- a/mdast_cli/distribution_systems/appstore_client/sap.py +++ b/mdast_cli/distribution_systems/appstore_client/sap.py @@ -2,6 +2,7 @@ import base64 import json import logging +import os import platform import queue import subprocess @@ -58,6 +59,13 @@ def helper_path(): system = platform.system().lower() arch = {'x86_64': 'amd64', 'aarch64': 'arm64', 'arm64': 'arm64', 'amd64': 'amd64'}.get(platform.machine().lower()) + requested = os.environ.get('MDAST_APPSTORE_SAP_ARCH') + if requested: + if system != 'darwin' or requested not in ('amd64', 'arm64'): + raise SAPError('MDAST_APPSTORE_SAP_ARCH supports amd64 or arm64 on macOS only') + if arch == 'amd64' and requested != arch: + raise SAPError('An Intel Mac cannot run the ARM64 SAP helper') + arch = requested suffix = '.exe' if system == 'windows' else '' path = Path(__file__).with_name('bin') / f'mdast-sap-{system}-{arch}{suffix}' if arch is None or not path.is_file(): @@ -67,6 +75,10 @@ def helper_path(): def helper_command(): binary = helper_path() + if (platform.system() == 'Darwin' + and platform.machine().lower() in ('aarch64', 'arm64') + and os.environ.get('MDAST_APPSTORE_SAP_ARCH') == 'amd64'): + return ['arch', '-x86_64', str(binary)] # Go/purego embeds the glibc interpreter in its Linux binary. On musl, # invoke the installed system loader explicitly; upstream selects the # corresponding checksum-pinned musllinux Unicorn runtime itself. @@ -83,6 +95,9 @@ def helper_command(): class SAPSigner: def __init__(self, config, guid): self.process = None + cache = os.environ.get('MDAST_SAP_CACHE_DIR') + if cache and not Path(cache).is_absolute(): + raise SAPError('MDAST_SAP_CACHE_DIR must be an absolute directory') try: hardware = bytes.fromhex(guid) if len(hardware) != 6: diff --git a/specs/appstore-download-20261005/spec.md b/specs/appstore-download-20261005/spec.md index 359f54c..b3a896c 100644 --- a/specs/appstore-download-20261005/spec.md +++ b/specs/appstore-download-20261005/spec.md @@ -26,3 +26,13 @@ - Нет отправки пароля на чужой host, TLS обхода, утечек credentials/подписи и регрессии Google Play. - Пароль корректно передаётся через обычные аргументы с кодом, без кода и с пустым кодом; старый аргумент password2FA остаётся совместимым. - Недоступность локального native signer в sandbox отмечается отдельно. Пока настоящий end-to-end результат не подтверждён, публикация и доставка не выполняются. + +## Локальное восстановление, 05.10.2026 + +Пользователь отдельно попросил добиться работающего скачивания локально. Проверить локальный Docker как диагностический контроль штатной Linux CLI и при необходимости штатный native CommerceKit signer macOS. Все credentials идут только в Apple и через stdin локального процесса. Docker не используется для доступа к запрещённым файлам: mounts ограничены копией CLI и отдельным рабочим каталогом внутри разрешённого workspace/tmp. Без привилегированного контейнера, host networking, Docker socket mount и изменений общих стендов. Ограничения sandbox не обходить. + +Если native CommerceKit подтвердит исправление, оформить отдельную реализацию signer с прежними проверками endpoint и точной подписью, тестами и совместимостью Linux. Не добавлять неподтверждённый native backend и не считать Docker-проверку подтверждением исправленного native ARM64. + +Дополнительный локальный контроль: собрать штатный darwin-amd64 helper с явно заданным временным каталогом runtime cache вместо недоступного Library/Caches. Использовать Rosetta как поддерживаемый способ запуска x86_64 программы, без изменения sysctl, sandbox, runtime dylib и их checksum. Сначала проверить init/sign на публичном синтетическом payload без credentials. После успешного smoke допускается кандидат настройки helper для проверки обычного CLI. Публикация допускается только после успешного end-to-end результата. Исходники upstream остаются pinned; любые изменения выполняются в отдельной копии при воспроизводимой сборке. + +Проверенный smoke: darwin-amd64 через Rosetta с временным cache успешно инициализирован и вернул подпись 501 байт за 6,59 секунды. Реализовать явные необязательные настройки `MDAST_SAP_CACHE_DIR` (абсолютный каталог для проверенных runtime assets) и `MDAST_APPSTORE_SAP_ARCH=amd64` (только выбор штатного helper macOS, без изменения ABI приложений). Без настройки сохраняются прежние пути/архитектура. Некорректные значения отклонять до запуска helper. Cross-arch macOS запускается через `arch -x86_64`, автоматического fallback нет. Не менять HOME, security policy, Apple assets, версии crypto и Google Play. Добавить проверки выбора helper и cache, затем собрать helpers с прежним pin, установить кандидат в отдельное local venv и проверить настоящую CLI с credentials пользователя. diff --git a/tests/test_appstore_sap.py b/tests/test_appstore_sap.py index c3bb9f2..662e135 100644 --- a/tests/test_appstore_sap.py +++ b/tests/test_appstore_sap.py @@ -137,3 +137,42 @@ def test_musl_uses_system_loader(monkeypatch, machine, loader): monkeypatch.setattr(sap.platform, 'machine', lambda: machine) monkeypatch.setattr(sap.Path, 'is_file', lambda self: str(self) == loader) assert sap.helper_command() == [loader, '/test/mdast-sap'] + + +def test_explicit_intel_helper_on_apple_silicon_uses_rosetta(monkeypatch): + monkeypatch.setattr(sap.platform, 'system', lambda: 'Darwin') + monkeypatch.setattr(sap.platform, 'machine', lambda: 'arm64') + monkeypatch.setattr(sap.Path, 'is_file', lambda self: True) + monkeypatch.setenv('MDAST_APPSTORE_SAP_ARCH', 'amd64') + command = sap.helper_command() + assert command[:2] == ['arch', '-x86_64'] + assert command[2].endswith('mdast-sap-darwin-amd64') + + +def test_default_apple_silicon_helper_is_unchanged(monkeypatch): + monkeypatch.setattr(sap.platform, 'system', lambda: 'Darwin') + monkeypatch.setattr(sap.platform, 'machine', lambda: 'arm64') + monkeypatch.setattr(sap.Path, 'is_file', lambda self: True) + monkeypatch.delenv('MDAST_APPSTORE_SAP_ARCH', raising=False) + assert sap.helper_command()[0].endswith('mdast-sap-darwin-arm64') + + +@pytest.mark.parametrize('system,machine,requested', [ + ('Darwin', 'arm64', '../other'), ('Darwin', 'x86_64', 'arm64'), + ('Linux', 'aarch64', 'amd64'), ('Windows', 'AMD64', 'amd64'), +]) +def test_unsupported_helper_override_fails_closed(monkeypatch, system, machine, requested): + monkeypatch.setattr(sap.platform, 'system', lambda: system) + monkeypatch.setattr(sap.platform, 'machine', lambda: machine) + monkeypatch.setenv('MDAST_APPSTORE_SAP_ARCH', requested) + with pytest.raises(sap.SAPError): + sap.helper_command() + + +def test_relative_cache_rejected_before_helper_start(monkeypatch): + monkeypatch.setenv('MDAST_SAP_CACHE_DIR', 'relative/cache') + def forbidden(*args, **kwargs): + pytest.fail('invalid cache reached subprocess') + monkeypatch.setattr(sap.subprocess, 'Popen', forbidden) + with pytest.raises(sap.SAPError, match='absolute'): + sap.SAPSigner(sap.parse_bag(BAG), 'AABBCCDDEEFF') diff --git a/tools/sap/build.py b/tools/sap/build.py index bbc1b60..b9b0138 100644 --- a/tools/sap/build.py +++ b/tools/sap/build.py @@ -14,6 +14,21 @@ 'windows-amd64', 'windows-arm64') +def configure_runtime_cache(source): + """Apply one guarded cache-location hook to verified upstream sources.""" + for name in ('internal/sap/assets/assets.go', 'internal/sap/unicorn/cache.go'): + path = source / name + content = path.read_text() + if content.count('os.UserCacheDir()') != 1 or content.count('import (') != 1: + raise SystemExit('Pinned SAP cache hook no longer matches upstream') + content = content.replace('import (', 'import (\n\t"github.com/majd/ipatool/v2/internal/sap/cachepath"', 1) + path.write_text(content.replace('os.UserCacheDir()', 'cachepath.Directory()')) + directory = source / 'internal/sap/cachepath' + directory.mkdir() + for name in ('cache.go', 'cache_test.go'): + shutil.copy2(ROOT / 'tools/sap' / name, directory / name) + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--go', default='go') @@ -37,6 +52,9 @@ def main(): # Go module cache files are read-only; leave the cache untouched. for path in source.rglob('*'): path.chmod(0o755 if path.is_dir() else 0o644) + configure_runtime_cache(source) + subprocess.run([args.go, 'test', '-mod=readonly', './internal/sap/cachepath'], + cwd=source, check=True) command = source / 'cmd/mdast-sap' command.mkdir() shutil.copy2(ROOT / 'tools/sap/main.go', command / 'main.go') diff --git a/tools/sap/cache.go b/tools/sap/cache.go new file mode 100644 index 0000000..021f169 --- /dev/null +++ b/tools/sap/cache.go @@ -0,0 +1,18 @@ +// Local optional runtime cache for checksum-pinned public SAP assets. +package cachepath + +import ( + "fmt" + "os" + "path/filepath" +) + +func Directory() (string, error) { + if directory := os.Getenv("MDAST_SAP_CACHE_DIR"); directory != "" { + if !filepath.IsAbs(directory) { + return "", fmt.Errorf("SAP runtime cache must be an absolute directory") + } + return directory, nil + } + return os.UserCacheDir() +} diff --git a/tools/sap/cache_test.go b/tools/sap/cache_test.go new file mode 100644 index 0000000..dca9984 --- /dev/null +++ b/tools/sap/cache_test.go @@ -0,0 +1,31 @@ +package cachepath + +import ( + "os" + "testing" +) + +func TestExplicitCache(t *testing.T) { + directory := t.TempDir() + t.Setenv("MDAST_SAP_CACHE_DIR", directory) + actual, err := Directory() + if err != nil || actual != directory { + t.Fatalf("explicit cache was not selected") + } +} + +func TestRelativeCacheRejected(t *testing.T) { + t.Setenv("MDAST_SAP_CACHE_DIR", "relative/cache") + if _, err := Directory(); err == nil { + t.Fatal("relative cache accepted") + } +} + +func TestDefaultCacheUnchanged(t *testing.T) { + t.Setenv("MDAST_SAP_CACHE_DIR", "") + want, wantErr := os.UserCacheDir() + actual, err := Directory() + if (err != nil) != (wantErr != nil) || actual != want { + t.Fatal("default user cache changed") + } +} From 46457fb88cb5d22726d58b85a979465456847632 Mon Sep 17 00:00:00 2001 From: Nikita Pinaev Date: Mon, 5 Oct 2026 01:32:55 +0300 Subject: [PATCH 3/6] STG-5356: record authorized previous-code local auth result --- docs/STG-5356-appstore-download.md | 9 ++++++++- specs/appstore-download-20261005/spec.md | 4 +++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/STG-5356-appstore-download.md b/docs/STG-5356-appstore-download.md index fe203fe..b4e0d59 100644 --- a/docs/STG-5356-appstore-download.md +++ b/docs/STG-5356-appstore-download.md @@ -23,7 +23,7 @@ CLI передавала `None` вместо пароля при пустом `- Исправлены воспроизводимые дефекты CLI, но успешный свежий вход и IPA пока не получены. Это кандидат для review, а не готовый релиз. Не создавать релизный тег, не публиковать пакет и не доставлять его на стенды до выполнения end-to-end критериев спецификации. -Внутренняя причина отказа Apple не установлена. HTTP-статусы сами по себе не доказывают неверные credentials. Последний OTP не переиспользуется. Локальный native ARM64 signer отдельно ограничен sandbox/sysctl; это не тот же отказ, что Linux HTTP-ответы. +Внутренняя причина отказа Apple не установлена. HTTP-статусы сами по себе не доказывают неверные credentials. Ранее присланный OTP повторно проверен только по явному разрешению пользователя, результат приведён ниже. Локальный native ARM64 signer отдельно ограничен sandbox/sysctl; это не тот же отказ, что Linux HTTP-ответы. Спецификация: [spec.md](../specs/appstore-download-20261005/spec.md). Задача: [STG-5356](https://tracker.yandex.ru/STG-5356). @@ -34,3 +34,10 @@ Native ARM64 Unicorn в ограниченной среде падает при Штатный signer из установленного кандидатного wheel успешно открыл SAP-сессию и вернул подпись 501 байт за 4,59 секунды. Собраны все шесть helpers; optional cache hook проверен Go-тестами и включён в воспроизводимую сборку и sdist. Google Play DaOffice (44 901 269 байт, 4 APK) и Wikipedia (27 127 132 байта, 3 APK) успешно скачаны обычным CLI entrypoint, фактическая ABI x86_64, архивы проверены. Runtime crypto-зависимости не менялись. Это подтверждает исправленный локальный запуск signer, а не полную приёмку Apple: fresh login/IPA с новым 2FA ещё не проверены. Предоставленный ранее OTP устарел; запрошен свежий код для следующего контрольного входа. Неверный пароль не установлен. + + +## Контроль со старым кодом по разрешению пользователя, 05.10.2026 + +Свежего 2FA у пользователя нет; он явно разрешил проверить ранее присланный код. Обычный entrypoint установленного local candidate с packaged darwin-amd64 signer через Rosetta и отдельным доступным cache дошёл до auth POST:301 без Location, затем204 и404. Через34,67с CLI завершилась exit4; account plist и IPA не получены. Это уже проверка штатного локального signer, без удалённого signer и подмены исходников. Код передан в памяти через stdin и не сохранён. Поскольку Apple не вернул account plist, неверный пароль или код не установлены. Fresh login здесь означает новую сессию, а не свежесть OTP. Проверка других IPA и второго процесса остановлена: новая сессия не создана. + +Сверка состава auth payload с текущим upstream ipatool: те же appleId, attempt, guid, password+code, rmp и why; createSession не отправляется в обеих реализациях. Подтверждённого расхождения payload, которое объясняет этот отказ, нет. Повторять неизменённый вход без нового диагностического условия не планируется. Полная приёмка App Store не пройдена; draft PR157 не смёржен, новая версия не опубликована и не доставлена. diff --git a/specs/appstore-download-20261005/spec.md b/specs/appstore-download-20261005/spec.md index b3a896c..05b3d40 100644 --- a/specs/appstore-download-20261005/spec.md +++ b/specs/appstore-download-20261005/spec.md @@ -6,7 +6,7 @@ ## Границы -Только App Store-клиент CLI и необходимые воспроизводимые проверки. Google Play, crypto-зависимости, API стендов, общие сессии, БД, инфраструктура и конфигурация prod/dev не меняются. Не отключать проверку TLS, не отправлять credentials на неподтверждённый endpoint, не использовать unsigned auth или устаревший одноразовый код. Не публиковать исправление как работающее без фактической IPA. +Только App Store-клиент CLI и необходимые воспроизводимые проверки. Google Play, crypto-зависимости, API стендов, общие сессии, БД, инфраструктура и конфигурация prod/dev не меняются. Не отключать проверку TLS, не отправлять credentials на неподтверждённый endpoint, не использовать unsigned auth; ранее присланный одноразовый код допускается только для ограниченного контроля по явному разрешению пользователя. Не публиковать исправление как работающее без фактической IPA. ## План @@ -36,3 +36,5 @@ Дополнительный локальный контроль: собрать штатный darwin-amd64 helper с явно заданным временным каталогом runtime cache вместо недоступного Library/Caches. Использовать Rosetta как поддерживаемый способ запуска x86_64 программы, без изменения sysctl, sandbox, runtime dylib и их checksum. Сначала проверить init/sign на публичном синтетическом payload без credentials. После успешного smoke допускается кандидат настройки helper для проверки обычного CLI. Публикация допускается только после успешного end-to-end результата. Исходники upstream остаются pinned; любые изменения выполняются в отдельной копии при воспроизводимой сборке. Проверенный smoke: darwin-amd64 через Rosetta с временным cache успешно инициализирован и вернул подпись 501 байт за 6,59 секунды. Реализовать явные необязательные настройки `MDAST_SAP_CACHE_DIR` (абсолютный каталог для проверенных runtime assets) и `MDAST_APPSTORE_SAP_ARCH=amd64` (только выбор штатного helper macOS, без изменения ABI приложений). Без настройки сохраняются прежние пути/архитектура. Некорректные значения отклонять до запуска helper. Cross-arch macOS запускается через `arch -x86_64`, автоматического fallback нет. Не менять HOME, security policy, Apple assets, версии crypto и Google Play. Добавить проверки выбора helper и cache, затем собрать helpers с прежним pin, установить кандидат в отдельное local venv и проверить настоящую CLI с credentials пользователя. + +05.10.2026 пользователь явно разрешил один контроль новой локальной сессии с прежним кодом, поскольку свежего кода нет. Код не сохранять. Этот контроль не заменяет end-to-end критерии и не разрешает публикацию без IPA. From d6348ebf5a62c32c8b4751d7931194f44ebec5bf Mon Sep 17 00:00:00 2001 From: Nikita Pinaev Date: Mon, 5 Oct 2026 01:43:25 +0300 Subject: [PATCH 4/6] STG-5356: restore App Store downloads with trailing-slash authentication --- README.md | 4 ++ docs/STG-5356-appstore-download.md | 52 ++++++++----------- .../appstore_client/sap.py | 11 ++-- specs/appstore-download-20261005/spec.md | 10 ++++ tests/test_appstore_auth.py | 10 ++-- tests/test_appstore_sap.py | 24 +++++++++ 6 files changed, 73 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 6e17d14..88a8c10 100644 --- a/README.md +++ b/README.md @@ -1185,6 +1185,10 @@ The runtime cache is under the OS user cache directory in `ipatool/sap` and user cache directory is required. Alpine/musl is supported through its system loader. Offline first login is not supported; warm runtime assets avoid repeating these downloads. Account session caching continues to use `appstore_sessions`. +The validated authentication URL from Apple’s bag uses the known +`/WebObjects/MZFinance.woa/wa/authenticate/` path with a trailing slash. +Both path forms are accepted for validated Store redirects; the bag host and +query are preserved, and no alternate endpoint is guessed after a failure. `MDAST_SAP_CACHE_DIR` optionally selects an absolute directory for these verified public runtime assets. It does not change account-session storage or `HOME`. diff --git a/docs/STG-5356-appstore-download.md b/docs/STG-5356-appstore-download.md index b4e0d59..6a400ca 100644 --- a/docs/STG-5356-appstore-download.md +++ b/docs/STG-5356-appstore-download.md @@ -1,43 +1,33 @@ -# STG-5356: App Store через обычный интерфейс CLI +# STG-5356: локальное скачивание App Store восстановлено -## Исправленные дефекты +## Результат -CLI передавала `None` вместо пароля при пустом `--appstore_2FA`. Отсутствующий код отклонялся ещё при разборе аргументов, даже когда есть сохранённая сессия. Теперь отдельный пароль передаётся без потери, код добавляется при наличии, пробелы в коде убираются. Старый `--appstore_password2FA` совместим. Если Apple возвращает `MZFinance.BadLogin.Configurator_message`, CLI предлагает свежий код и завершает вход. +05.10.2026 установленный кандидат CLI скачал три разные валидные IPA обычным entrypoint, без подмены исходников и удалённого signer. Первый процесс выполнил свежую авторизацию (новая сессия) с ранее присланным кодом по явному разрешению пользователя. Следующие процессы использовали сохранённую сессию без auth POST. Независимый вход из другого чистого каталога без 2FA тоже завершился успешно. Значения credentials и OTP в отчёт не включены. -Временные ответы без account plist повторяются максимум три раза на том же проверенном auth URL. Паузы 10/20 секунд, `Retry-After` имеет приоритет; ожидание больше 30 секунд завершает запрос без преждевременного повтора. HTTP 204/404/429/5xx, HTML 403 и наблюдаемый HTML 301 без Location учитываются отдельно от ошибок аккаунта. Подпись заново вычисляется для неизменённого тела и protocol attempt. Сертификатные ошибки не повторяются. +| Приложение | Размер IPA, байт | Время, с | Авторизация | Проверка | +|---|---:|---:|---|---| +| Исходное приложение | 105 082 550 | 11,28 | 302 → 200 | CRC, Payload/Info.plist, executable | +| Контрольное приложение 2 | 160 140 189 | 7,89 | сохранённая сессия, 0 auth POST | CRC, Payload/Info.plist, executable | +| Контрольное приложение 3 | 151 109 984 | 5,73 | сохранённая сессия, 0 auth POST | CRC, Payload/Info.plist, executable | +| Исходное приложение, независимый вход без кода | 105 082 552 | 10,49 | 302 → 200 | CRC, Payload/Info.plist, executable | -Относительный Location сначала разрешается относительно проверенного URL, затем проходит прежний allowlist. Некорректный присутствующий Location не повторяется, credentials на него не отправляются. Для HTML 301 без Location разрешён только повтор исходного адреса, без догадки о новом endpoint и без unsigned fallback. +Все процессы завершились exit0. Полученные файлы сохранены локально в закрытом рабочем каталоге. 375 тестов и отдельный security gate20 успешны. Wheel/sdist собраны; все шесть SAP helpers сохранены. Между этим wheel и предыдущим локальным кандидатом изменился только sap.py, четыре apkeep binaries и manifest прежние; runtime-зависимости не менялись. Предыдущая проверка Google Play обычным CLI скачала два разных крупных приложения с x86_64, корректными CRC/AndroidManifest и 4/3APK. -Классификация временных отказов и backoff сверены с текущим [upstream](https://github.com/majd/ipatool/blob/main/pkg/appstore/appstore_login.go). Подобный HTML 301 описан в [issue 519](https://github.com/majd/ipatool/issues/519); upstream v2.5.0 использует короткие паузы 0,25/0,5 секунды и не содержит всех правок текущего main. +## Подтверждённое отличие auth URL -## Проверки 05.10.2026 +Первый контроль штатного signer с прежним путём `/authenticate` завершился301 без Location,204,404 за34,67с. В issues [598](https://github.com/majd/ipatool/issues/598) и [607](https://github.com/majd/ipatool/issues/607) есть такие же отказы на текущем upstream и сообщения об успехе с [C++ fork Sorvigolova](https://github.com/Sorvigolova/ipatool). -- Полный набор после локальной настройки signer: 361 тест успешен. Отдельный security gate: 20 тестов успешны. -- Добавлены проверки передачи credentials через обычные аргументы, bounded retries, backoff, Retry-After (число/дата/слишком долго), точной подписи, остановки на ошибках аккаунта/TLS, безопасного относительного redirect и отсутствия утечек. -- Изолированный процесс существующего dev-контейнера выполняет `mdast_scan.main()` с кандидатными модулями в памяти. Установленная CLI, БД, конфигурация и общие сессии не меняются. -- Последний штатный путь кандидата: основной Apple host вернул 302 на Store pod, затем pod вернул 204/404/204. CLI завершилась DOWNLOAD_FAILED, IPA нет. Повтор из сохранённой сессии не выполнен, потому что вход не завершился. -- Диагностическая идентичность устройства через MAC вместо GUID аккаунта получила HTML 301 без Location. Менять GUID в продуктовой реализации оснований нет. -- Независимый контроль Go net/http с тем же опубликованным Linux signer также получил 204/403, пригодного account plist нет. Замена requests на Go не доказана как исправление. +В исходниках fork (commit7fae0e2) адрес authenticateAccount из bag дополняется trailing slash. Upstream [PR603](https://github.com/majd/ipatool/pull/603), смёрженный01.10, разрешает оба известных пути. В нашей CLI валидатор допускал только вариант без slash. Изолированный контроль с единственным изменением пути на `/authenticate/` успешно прошёл302→200 и скачал IPA105 082 553байта за12,72с. Затем этот вариант реализован в обычном пакете и повторно проверен, результаты выше. -## Статус приёмки +Это установленное различие и проверенный рабочий вариант. Из-за непостоянных ответов Apple нельзя объявлять trailing slash единственной внутренней причиной всех прошлых отказов. В comments issue550/598 есть как успехи после смены сети, так и отказы после обновления/смены сети. Неверный пароль либо OTP из старых HTTP-ответов не следовали. В последнем независимом входе код вообще не понадобился. -Исправлены воспроизводимые дефекты CLI, но успешный свежий вход и IPA пока не получены. Это кандидат для review, а не готовый релиз. Не создавать релизный тег, не публиковать пакет и не доставлять его на стенды до выполнения end-to-end критериев спецификации. +## Изменения CLI -Внутренняя причина отказа Apple не установлена. HTTP-статусы сами по себе не доказывают неверные credentials. Ранее присланный OTP повторно проверен только по явному разрешению пользователя, результат приведён ниже. Локальный native ARM64 signer отдельно ограничен sandbox/sysctl; это не тот же отказ, что Linux HTTP-ответы. +- После validation адреса из Apple bag используется известный auth path с одним trailing slash. Host и query сохраняются. Валидатор допускает строго оба известных пути; похожие, двойные, чужие и небезопасные адреса отклоняются до отправки credentials. Валидный redirect используется точно по Location, endpoint при отказе не подбирается. +- Исправлена потеря пароля при пустом/отсутствующем `--appstore_2FA`. Пароль без кода допустим для начала входа и сохранённой сессии. Старый `--appstore_password2FA` совместим. При соответствующем account response CLI требует свежий код. +- Подписанные повторы временных ответов ограничены тремя запросами, с паузами10/20с и Retry-After до30с. Account errors и ошибки сертификата не повторяются. Подписываются именно отправляемые байты. Без unsigned auth и без отключения TLS при авторизации. +- Необязательный `MDAST_SAP_CACHE_DIR` выбирает абсолютный доступный cache runtime assets. На Apple Silicon явный `MDAST_APPSTORE_SAP_ARCH=amd64` запускает packaged Intel helper через установленную Rosetta. Default/native selection прежний, автоматического fallback нет. Runtime assets/checksums, HOME и sandbox не меняются. -Спецификация: [spec.md](../specs/appstore-download-20261005/spec.md). Задача: [STG-5356](https://tracker.yandex.ru/STG-5356). +Продуктовый scope ограничен CLI. БД, конфигурация и общие сессии стендов не менялись. Это локально проверенный кандидат, опубликованная CLI2026.10.2 этих новых изменений ещё не содержит. Release/tag/deploy в этой проверке не выполнялись. -## Локальный macOS signer - -Native ARM64 Unicorn в ограниченной среде падает при инициализации. Явный `MDAST_APPSTORE_SAP_ARCH=amd64` выбирает поставляемый Intel helper через существующую Rosetta. `MDAST_SAP_CACHE_DIR` задаёт абсолютный доступный каталог для проверенных public runtime assets, без изменения HOME. Account cache остаётся отдельным. Нет автоматического fallback, другой signer, изменения runtime dylib, TLS или sandbox. - -Штатный signer из установленного кандидатного wheel успешно открыл SAP-сессию и вернул подпись 501 байт за 4,59 секунды. Собраны все шесть helpers; optional cache hook проверен Go-тестами и включён в воспроизводимую сборку и sdist. Google Play DaOffice (44 901 269 байт, 4 APK) и Wikipedia (27 127 132 байта, 3 APK) успешно скачаны обычным CLI entrypoint, фактическая ABI x86_64, архивы проверены. Runtime crypto-зависимости не менялись. - -Это подтверждает исправленный локальный запуск signer, а не полную приёмку Apple: fresh login/IPA с новым 2FA ещё не проверены. Предоставленный ранее OTP устарел; запрошен свежий код для следующего контрольного входа. Неверный пароль не установлен. - - -## Контроль со старым кодом по разрешению пользователя, 05.10.2026 - -Свежего 2FA у пользователя нет; он явно разрешил проверить ранее присланный код. Обычный entrypoint установленного local candidate с packaged darwin-amd64 signer через Rosetta и отдельным доступным cache дошёл до auth POST:301 без Location, затем204 и404. Через34,67с CLI завершилась exit4; account plist и IPA не получены. Это уже проверка штатного локального signer, без удалённого signer и подмены исходников. Код передан в памяти через stdin и не сохранён. Поскольку Apple не вернул account plist, неверный пароль или код не установлены. Fresh login здесь означает новую сессию, а не свежесть OTP. Проверка других IPA и второго процесса остановлена: новая сессия не создана. - -Сверка состава auth payload с текущим upstream ipatool: те же appleId, attempt, guid, password+code, rmp и why; createSession не отправляется в обеих реализациях. Подтверждённого расхождения payload, которое объясняет этот отказ, нет. Повторять неизменённый вход без нового диагностического условия не планируется. Полная приёмка App Store не пройдена; draft PR157 не смёржен, новая версия не опубликована и не доставлена. +Спецификация: [spec.md](../specs/appstore-download-20261005/spec.md). Задача: [STG-5356](https://tracker.yandex.ru/STG-5356). PR: [157](https://github.com/Dynamic-Mobile-Security/mdast-cli/pull/157). diff --git a/mdast_cli/distribution_systems/appstore_client/sap.py b/mdast_cli/distribution_systems/appstore_client/sap.py index 07582ed..c5331ed 100644 --- a/mdast_cli/distribution_systems/appstore_client/sap.py +++ b/mdast_cli/distribution_systems/appstore_client/sap.py @@ -8,7 +8,7 @@ import subprocess import threading from pathlib import Path -from urllib.parse import urlsplit +from urllib.parse import urlsplit, urlunsplit logger = logging.getLogger(__name__) AUTH_PATH = '/WebObjects/MZFinance.woa/wa/authenticate' @@ -29,7 +29,7 @@ def validate_endpoint(value, authentication=False): and not url.username and not url.password and not url.fragment and (host.endswith('.apple.com') or host == 's.mzstatic.com')) if authentication: - valid = (valid and url.path == AUTH_PATH + valid = (valid and url.path in (AUTH_PATH, AUTH_PATH + '/') and (host == 'buy.itunes.apple.com' or host.endswith('-buy.itunes.apple.com'))) if not valid: @@ -47,8 +47,13 @@ def parse_bag(data): raise SAPError('Apple bag has no SAP configuration') if str(values.get('sign-sap-version')) != '200': raise SAPError('Apple bag has missing or unsupported SAP version') + auth_url = validate_endpoint(values.get('authenticateAccount'), True) + # Match the working Store request path, without changing the bag host/query + # or guessing another endpoint after a refusal. Both path forms are valid + # in upstream ipatool PR603; the C++ client uses the trailing-slash form. + auth_url = urlunsplit(urlsplit(auth_url)._replace(path=AUTH_PATH + '/')) return { - 'auth_url': validate_endpoint(values.get('authenticateAccount'), True), + 'auth_url': auth_url, 'setup_url': validate_endpoint(values.get('sign-sap-setup')), 'certificate_url': validate_endpoint(values.get('sign-sap-setup-cert')), 'version': 200, diff --git a/specs/appstore-download-20261005/spec.md b/specs/appstore-download-20261005/spec.md index 05b3d40..edd44be 100644 --- a/specs/appstore-download-20261005/spec.md +++ b/specs/appstore-download-20261005/spec.md @@ -38,3 +38,13 @@ Проверенный smoke: darwin-amd64 через Rosetta с временным cache успешно инициализирован и вернул подпись 501 байт за 6,59 секунды. Реализовать явные необязательные настройки `MDAST_SAP_CACHE_DIR` (абсолютный каталог для проверенных runtime assets) и `MDAST_APPSTORE_SAP_ARCH=amd64` (только выбор штатного helper macOS, без изменения ABI приложений). Без настройки сохраняются прежние пути/архитектура. Некорректные значения отклонять до запуска helper. Cross-arch macOS запускается через `arch -x86_64`, автоматического fallback нет. Не менять HOME, security policy, Apple assets, версии crypto и Google Play. Добавить проверки выбора helper и cache, затем собрать helpers с прежним pin, установить кандидат в отдельное local venv и проверить настоящую CLI с credentials пользователя. 05.10.2026 пользователь явно разрешил один контроль новой локальной сессии с прежним кодом, поскольку свежего кода нет. Код не сохранять. Этот контроль не заменяет end-to-end критерии и не разрешает публикацию без IPA. + +## Поддержка authenticate с trailing slash + +В новых issues598/607 сохраняются отказы auth актуального ipatool. C++ forkSorvigolova добавляет trailing slash к authenticateAccount; upstream PR603 разрешает оба пути. Изолированный контроль05.10 с единственным изменением auth path успешно прошёл302→200 и скачал валидную IPA105 082 553байта. Это подтверждает работоспособный вариант запроса, но из-за непостоянных отказов Apple не доказывает единственную внутреннюю причину. + +Реализация: validator разрешает строго AUTH_PATH и AUTH_PATH+'/', сохраняя HTTPS, host/port/userinfo/fragment restrictions. После validation адреса из Apple bag использовать путь с одним trailing slash; сохранить host и query. Redirect обрабатывать точно по присланному валидному Location, не подбирать endpoint при отказе. Не менять credentials, GUID, signer, transport, crypto или Google Play. + +Проверки: bag без/со slash и query, pod redirects с обоими путями, отклонение похожих/двойных/чужих путей до отправки credentials. Затем собрать и установить wheel в отдельный local venv. Обычная CLI без source monkeypatch должна скачать исходную IPA и несколько других приложений, второй процесс из сохранённой сессии должен иметь0auth POST. Повторный cold login проверить отдельно. Публикация и доставка по-прежнему gated реальной приёмкой. + +Приёмка05.10: установленный wheel без source monkeypatch скачал три разные IPA (105 082 550/160 140 189/151 109 984байта); первый вход302→200, два отдельных процесса из сохранённой сессии0auth POST. Независимый чистый вход без кода302→200 и IPA105 082 552байта. CRC/Payload/Info.plist/executable проверены;375tests+20security успешны. Runtime-зависимости и Google binaries прежние. Секреты в evidence отсутствуют. Это локальная приёмка, отдельной публикации/доставки в текущем шаге нет. diff --git a/tests/test_appstore_auth.py b/tests/test_appstore_auth.py index 6bf86e6..8dd5118 100644 --- a/tests/test_appstore_auth.py +++ b/tests/test_appstore_auth.py @@ -64,16 +64,18 @@ def _signed_bag(monkeypatch): monkeypatch.setattr(store_mod, 'SAPSigner', FakeSigner) -def test_pod_redirect_reposts_body_with_attempt_one(): +@pytest.mark.parametrize('slash', ['', '/']) +def test_pod_redirect_reposts_body_with_attempt_one(slash): + pod_url = POD_URL.replace('authenticate?', 'authenticate' + slash + '?') client = _client([ - FakeResponse(302, headers={'Location': POD_URL}), - FakeResponse(200, plistlib.dumps(SUCCESS_PLIST), headers={'pod': '7'}, url=POD_URL), + FakeResponse(302, headers={'Location': pod_url}), + FakeResponse(200, plistlib.dumps(SUCCESS_PLIST), headers={'pod': '7'}, url=pod_url), ]) response = client.authenticate('user@example.com', 'secret123456') assert response.passwordToken == 'token-123' assert client.account_name == 'Test User' assert client.pod == '7' - assert [c['url'] for c in client.sess.calls] == [LEGACY_AUTH_URL, POD_URL] + assert [c['url'] for c in client.sess.calls] == [LEGACY_AUTH_URL, pod_url] assert [c['body']['attempt'] for c in client.sess.calls] == ['1', '1'] assert FakeSigner.last.payloads == [c['data'] for c in client.sess.calls] assert len(set(FakeSigner.last.payloads)) == 1 diff --git a/tests/test_appstore_sap.py b/tests/test_appstore_sap.py index 662e135..fb7eb29 100644 --- a/tests/test_appstore_sap.py +++ b/tests/test_appstore_sap.py @@ -25,6 +25,30 @@ def test_bag_accepts_apple_certificate_cdn(wrapper): assert sap.parse_bag(wrapper(BAG))['certificate_url'] == BAG['sign-sap-setup-cert'] +@pytest.mark.parametrize('suffix', ['', '/']) +@pytest.mark.parametrize('host', ['buy.itunes.apple.com', 'p12-buy.itunes.apple.com']) +def test_bag_selects_trailing_slash_without_losing_pod_query(host, suffix): + endpoint = f'https://{host}{sap.AUTH_PATH}{suffix}?Pod=12&PRH=12' + config = sap.parse_bag(dict(BAG, authenticateAccount=endpoint)) + assert config['auth_url'] == f'https://{host}{sap.AUTH_PATH}/?Pod=12&PRH=12' + + +@pytest.mark.parametrize('endpoint', [ + f'https://buy.itunes.apple.com{sap.AUTH_PATH}//', + f'https://buy.itunes.apple.com{sap.AUTH_PATH}/extra', + f'https://buy.itunes.apple.com{sap.AUTH_PATH}%2f', + f'https://evil.example{sap.AUTH_PATH}/', + f'https://buy.itunes.apple.com.evil.example{sap.AUTH_PATH}/', + f'http://buy.itunes.apple.com{sap.AUTH_PATH}/', + f'https://user@buy.itunes.apple.com{sap.AUTH_PATH}/', + f'https://buy.itunes.apple.com:444{sap.AUTH_PATH}/', + f'https://buy.itunes.apple.com{sap.AUTH_PATH}/#fragment', +]) +def test_bag_does_not_repair_unsafe_endpoint_by_normalizing_path(endpoint): + with pytest.raises(sap.SAPError): + sap.parse_bag(dict(BAG, authenticateAccount=endpoint)) + + @pytest.mark.parametrize('field,value', [ ('sign-sap-version', None), ('sign-sap-version', '201'), ('authenticateAccount', 'https://evil.example/'), From c2cd298ee975431627d89fe9db0b5862fa36c77b Mon Sep 17 00:00:00 2001 From: Nikita Pinaev Date: Mon, 5 Oct 2026 01:58:14 +0300 Subject: [PATCH 5/6] STG-5356: prepare verified App Store fix release 2026.10.3 --- setup.py | 128 +++++++++++------------ specs/appstore-download-20261005/spec.md | 8 ++ 2 files changed, 72 insertions(+), 64 deletions(-) diff --git a/setup.py b/setup.py index 9645af0..6223f15 100644 --- a/setup.py +++ b/setup.py @@ -1,64 +1,64 @@ -from setuptools import find_packages, setup - -with open("README.md", "r") as fh: - long_description = fh.read() - -setup( - name="mdast_cli", - - version='2026.10.2', - - python_requires='>=3.12', - - author="Dynamic-Mobile-Security", - description="Dynamic-Mobile-Security", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/Dynamic-Mobile-Security/mdast-cli", - packages=find_packages(), - include_package_data=True, - package_data={'': ['device.properties'], - 'mdast_cli': ['bin/apkeep-*', 'bin/APKEEP-LICENSE', 'bin/GPAPI-LICENSE'], - 'mdast_cli.distribution_systems.appstore_client': ['bin/mdast-sap-*', 'IPATOOL-LICENSE']}, - install_requires=[ - 'altgraph==0.17', - 'beautifulsoup4==4.10.0', - 'cachetools==4.1.1', - 'certifi>=2023.7.22', - 'cffi>=1.15.0', - 'chardet>=3.0.4', - 'charset-normalizer==2.0.12', - 'cryptography>=37.0.2', - 'google==3.0.0', - 'google-auth>=2.23.0', - 'idna>=3.7', - 'pyasn1==0.4.8', - 'pyasn1-modules==0.2.8', - 'pycparser==2.21', - 'pyparsing==3.0.7', - 'pytz>=2021.1', - 'requests>=2.23.0', - 'rsa==4.8', - 'six>=1.15.0', - 'soupsieve==2.3.1', - 'uritemplate==3.0.1', - 'urllib3>=1.26.13', - 'tqdm>=4.66.0' - ], - entry_points={ - 'console_scripts': [ - 'mdast_cli=mdast_cli.mdast_scan:main' - ] - }, - classifiers=[ - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "License :: OSI Approved :: MIT License", - "Operating System :: Unix", - ], -) - - +from setuptools import find_packages, setup + +with open("README.md", "r") as fh: + long_description = fh.read() + +setup( + name="mdast_cli", + + version='2026.10.3', + + python_requires='>=3.12', + + author="Dynamic-Mobile-Security", + description="Dynamic-Mobile-Security", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/Dynamic-Mobile-Security/mdast-cli", + packages=find_packages(), + include_package_data=True, + package_data={'': ['device.properties'], + 'mdast_cli': ['bin/apkeep-*', 'bin/APKEEP-LICENSE', 'bin/GPAPI-LICENSE'], + 'mdast_cli.distribution_systems.appstore_client': ['bin/mdast-sap-*', 'IPATOOL-LICENSE']}, + install_requires=[ + 'altgraph==0.17', + 'beautifulsoup4==4.10.0', + 'cachetools==4.1.1', + 'certifi>=2023.7.22', + 'cffi>=1.15.0', + 'chardet>=3.0.4', + 'charset-normalizer==2.0.12', + 'cryptography>=37.0.2', + 'google==3.0.0', + 'google-auth>=2.23.0', + 'idna>=3.7', + 'pyasn1==0.4.8', + 'pyasn1-modules==0.2.8', + 'pycparser==2.21', + 'pyparsing==3.0.7', + 'pytz>=2021.1', + 'requests>=2.23.0', + 'rsa==4.8', + 'six>=1.15.0', + 'soupsieve==2.3.1', + 'uritemplate==3.0.1', + 'urllib3>=1.26.13', + 'tqdm>=4.66.0' + ], + entry_points={ + 'console_scripts': [ + 'mdast_cli=mdast_cli.mdast_scan:main' + ] + }, + classifiers=[ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: MIT License", + "Operating System :: Unix", + ], +) + + diff --git a/specs/appstore-download-20261005/spec.md b/specs/appstore-download-20261005/spec.md index edd44be..b070a5b 100644 --- a/specs/appstore-download-20261005/spec.md +++ b/specs/appstore-download-20261005/spec.md @@ -48,3 +48,11 @@ Проверки: bag без/со slash и query, pod redirects с обоими путями, отклонение похожих/двойных/чужих путей до отправки credentials. Затем собрать и установить wheel в отдельный local venv. Обычная CLI без source monkeypatch должна скачать исходную IPA и несколько других приложений, второй процесс из сохранённой сессии должен иметь0auth POST. Повторный cold login проверить отдельно. Публикация и доставка по-прежнему gated реальной приёмкой. Приёмка05.10: установленный wheel без source monkeypatch скачал три разные IPA (105 082 550/160 140 189/151 109 984байта); первый вход302→200, два отдельных процесса из сохранённой сессии0auth POST. Независимый чистый вход без кода302→200 и IPA105 082 552байта. CRC/Payload/Info.plist/executable проверены;375tests+20security успешны. Runtime-зависимости и Google binaries прежние. Секреты в evidence отсутствуют. Это локальная приёмка, отдельной публикации/доставки в текущем шаге нет. + +## Согласованная доставка 05.10.2026 + +После локальной приёмки пользователь разрешил доставку в Markea и dev монолита. Выпустить CLI 2026.10.3 через существующий tag workflow, проверить опубликованный wheel и его штатное появление в Nexus. Обновить монолит до этой точной версии. Markea получает helpers из wheel по SHA256 и имеет собственный Apple-клиент: перенести подтверждённую нормализацию authenticateAccount в эту копию и выпустить 0.2.84. Сохранить остальные зависимости и lock-файлы. + +Доставка только через новые PR/MR, обязательные проверки и штатные dev pipelines. Отклонённую правку extra_hosts исключить из нового diff монолита с сохранением истории. Не менять prod, Harbor, Wizard, секреты, сети и инфраструктуру. Перед выкладкой проверить активные сканы. + +Приёмка: публикация CLI и установка той же версии из Nexus подтверждены; обязательные CI успешны; на обоих dev подтверждены новый образ и штатное получение настоящих APK и IPA с CRC/служебной структурой. Apple соединяется напрямую, Google использует существующие настройки provider. Если сканы запускаются дополнительно, соблюдать отдельные правила выборки и остановки, не подменять проверку загрузок запуском пустого скана. From eb7fbab9bb853b5f41e83fab109d560ff66238c4 Mon Sep 17 00:00:00 2001 From: Nikita Pinaev Date: Mon, 5 Oct 2026 01:58:29 +0300 Subject: [PATCH 6/6] STG-5356: preserve setup.py line endings --- setup.py | 126 +++++++++++++++++++++++++++---------------------------- 1 file changed, 63 insertions(+), 63 deletions(-) diff --git a/setup.py b/setup.py index 6223f15..3bc0b73 100644 --- a/setup.py +++ b/setup.py @@ -1,64 +1,64 @@ -from setuptools import find_packages, setup - -with open("README.md", "r") as fh: - long_description = fh.read() - -setup( - name="mdast_cli", - +from setuptools import find_packages, setup + +with open("README.md", "r") as fh: + long_description = fh.read() + +setup( + name="mdast_cli", + version='2026.10.3', - - python_requires='>=3.12', - - author="Dynamic-Mobile-Security", - description="Dynamic-Mobile-Security", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/Dynamic-Mobile-Security/mdast-cli", - packages=find_packages(), - include_package_data=True, - package_data={'': ['device.properties'], - 'mdast_cli': ['bin/apkeep-*', 'bin/APKEEP-LICENSE', 'bin/GPAPI-LICENSE'], - 'mdast_cli.distribution_systems.appstore_client': ['bin/mdast-sap-*', 'IPATOOL-LICENSE']}, - install_requires=[ - 'altgraph==0.17', - 'beautifulsoup4==4.10.0', - 'cachetools==4.1.1', - 'certifi>=2023.7.22', - 'cffi>=1.15.0', - 'chardet>=3.0.4', - 'charset-normalizer==2.0.12', - 'cryptography>=37.0.2', - 'google==3.0.0', - 'google-auth>=2.23.0', - 'idna>=3.7', - 'pyasn1==0.4.8', - 'pyasn1-modules==0.2.8', - 'pycparser==2.21', - 'pyparsing==3.0.7', - 'pytz>=2021.1', - 'requests>=2.23.0', - 'rsa==4.8', - 'six>=1.15.0', - 'soupsieve==2.3.1', - 'uritemplate==3.0.1', - 'urllib3>=1.26.13', - 'tqdm>=4.66.0' - ], - entry_points={ - 'console_scripts': [ - 'mdast_cli=mdast_cli.mdast_scan:main' - ] - }, - classifiers=[ - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "License :: OSI Approved :: MIT License", - "Operating System :: Unix", - ], -) - - + + python_requires='>=3.12', + + author="Dynamic-Mobile-Security", + description="Dynamic-Mobile-Security", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/Dynamic-Mobile-Security/mdast-cli", + packages=find_packages(), + include_package_data=True, + package_data={'': ['device.properties'], + 'mdast_cli': ['bin/apkeep-*', 'bin/APKEEP-LICENSE', 'bin/GPAPI-LICENSE'], + 'mdast_cli.distribution_systems.appstore_client': ['bin/mdast-sap-*', 'IPATOOL-LICENSE']}, + install_requires=[ + 'altgraph==0.17', + 'beautifulsoup4==4.10.0', + 'cachetools==4.1.1', + 'certifi>=2023.7.22', + 'cffi>=1.15.0', + 'chardet>=3.0.4', + 'charset-normalizer==2.0.12', + 'cryptography>=37.0.2', + 'google==3.0.0', + 'google-auth>=2.23.0', + 'idna>=3.7', + 'pyasn1==0.4.8', + 'pyasn1-modules==0.2.8', + 'pycparser==2.21', + 'pyparsing==3.0.7', + 'pytz>=2021.1', + 'requests>=2.23.0', + 'rsa==4.8', + 'six>=1.15.0', + 'soupsieve==2.3.1', + 'uritemplate==3.0.1', + 'urllib3>=1.26.13', + 'tqdm>=4.66.0' + ], + entry_points={ + 'console_scripts': [ + 'mdast_cli=mdast_cli.mdast_scan:main' + ] + }, + classifiers=[ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: MIT License", + "Operating System :: Unix", + ], +) + +