diff --git a/.github/workflows/docker-hub-publish.yml b/.github/workflows/docker-hub-publish.yml index 85b7360..1450134 100644 --- a/.github/workflows/docker-hub-publish.yml +++ b/.github/workflows/docker-hub-publish.yml @@ -34,7 +34,7 @@ jobs: - name: Build the Docker image - run: docker build . --file Dockerfile -t mobilesecurity/mdast_cli:2026.8.4 -t mobilesecurity/mdast_cli:latest + run: docker build . --file Dockerfile -t mobilesecurity/mdast_cli:2026.8.5 -t mobilesecurity/mdast_cli:latest - name: Docker Hub push latest image @@ -42,6 +42,5 @@ jobs: - name: Docker Hub push tagged image - run: docker push mobilesecurity/mdast_cli:2026.8.4 - + run: docker push mobilesecurity/mdast_cli:2026.8.5 diff --git a/mdast_cli/__init__.py b/mdast_cli/__init__.py index 9f3ad9c..69cfa87 100644 --- a/mdast_cli/__init__.py +++ b/mdast_cli/__init__.py @@ -1 +1 @@ -__version__ = '2026.8.4' +__version__ = '2026.8.5' diff --git a/mdast_cli/ms_flow.py b/mdast_cli/ms_flow.py index b014cb9..9c12cfe 100644 --- a/mdast_cli/ms_flow.py +++ b/mdast_cli/ms_flow.py @@ -26,6 +26,7 @@ ENGINE_ACTIVE_STATUS, ScanStage, ScanStageStatus) from mdast_cli.helpers.exit_codes import ExitCode from mdast_cli.helpers.helpers import check_app_md5, resolve_report_targets +from mdast_cli_core.factory import architecture_items from mdast_cli_core.microservices import extract_error_message, mDastMicroservices logger = logging.getLogger(__name__) @@ -78,7 +79,8 @@ def resolve_platform(app_file): def resolve_ms_os_version(architectures, platform): """Choose a deterministic Scanyon OS version for a CLI scan.""" - if not isinstance(architectures, list): + architectures = architecture_items(architectures) + if architectures is None: return None candidates = [ @@ -314,7 +316,12 @@ def _run_microservices_flow(arguments, url, token, app_file, user_agent, verify) architectures_resp = mdast.get_architectures() if architectures_resp.status_code != 200: _exit_on_http_error(architectures_resp, 'Getting architectures', ExitCode.NETWORK_ERROR) - architectures = _json_or_exit(architectures_resp, 'Getting architectures') + architectures_payload = _json_or_exit(architectures_resp, 'Getting architectures') + architectures = architecture_items(architectures_payload) + if architectures is None: + logger.error('Getting architectures: unexpected response shape ' + '(expected a list or paginated items envelope)') + sys.exit(ExitCode.NETWORK_ERROR) logger.info(f'Supported architectures: {architectures}') platform = resolve_platform(app_file) diff --git a/mdast_cli_core/factory.py b/mdast_cli_core/factory.py index 2a12297..accd46d 100644 --- a/mdast_cli_core/factory.py +++ b/mdast_cli_core/factory.py @@ -9,8 +9,9 @@ 2. Auto-detection by *content fingerprint*, not just an HTTP 200. Both installations expose ``GET {base}/architectures/`` under ``/rest``, but the payloads differ structurally: - - microservices (scanyon-native): ``type`` is a string ``ANDROID``/``IOS`` - and each item carries ``os_version``; + - microservices (scanyon-native): a list or paginated ``items`` envelope; + ``type`` is a string ``ANDROID``/``IOS`` and each item carries + ``os_version``; - monolith: ``type`` is an integer code (1/2) and there is no ``os_version``. Auth scheme also differs (microservices = ``Bearer``, monolith = ``Token``), so each probe uses its own scheme. A monolith that happens to answer 200 to a @@ -66,11 +67,27 @@ def tls_verify_enabled(): return raw not in ('0', 'false', 'no', 'off') +def architecture_items(payload): + """Return architecture rows from legacy list or Scanyon Page envelope. + + STG-4892 paginated ``GET /architectures/`` as + ``{items, total, page, size, pages}``. Older Scanyon versions and the + monolith still return a bare list, so the CLI must accept both shapes. + ``None`` distinguishes a malformed payload from a valid empty catalogue. + """ + if isinstance(payload, list): + return payload + if isinstance(payload, dict) and isinstance(payload.get('items'), list): + return payload['items'] + return None + + def _looks_microservices(payload): """True if an /architectures/ payload is scanyon-native (microservices).""" - if not isinstance(payload, list) or not payload: + items = architecture_items(payload) + if not items: return False - item = payload[0] + item = items[0] if not isinstance(item, dict): return False # scanyon-native: type is a string ANDROID/IOS and os_version is present @@ -80,9 +97,10 @@ def _looks_microservices(payload): def _looks_monolith(payload): """True if an /architectures/ payload is monolith-shaped (int type code).""" - if not isinstance(payload, list) or not payload: + items = architecture_items(payload) + if not items: return False - item = payload[0] + item = items[0] return isinstance(item, dict) and isinstance(item.get('type'), int) @@ -242,7 +260,10 @@ def resolve_installation_mode(base_url, ci_token, company_id, mode=None, verify= # Ambiguous 200 (payload matched neither shape) — do not guess. if ms.status == 200 or mono.status == 200: - empty_list = ms.payload == [] or mono.payload == [] + empty_list = ( + architecture_items(ms.payload) == [] or + architecture_items(mono.payload) == [] + ) hint = ('The /architectures/ list is empty, so the installation flavour cannot be ' 'inferred from it. ' if empty_list else 'The payload matched neither the microservices (string type + os_version) ' diff --git a/setup.py b/setup.py index bb606b7..546a936 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name="mdast_cli", - version='2026.8.4', + version='2026.8.5', python_requires='>=3.12', diff --git a/tests/test_mode_detection.py b/tests/test_mode_detection.py index 536d779..bcde827 100644 --- a/tests/test_mode_detection.py +++ b/tests/test_mode_detection.py @@ -15,6 +15,7 @@ ARCH_URL = f'{REST_URL}/architectures/' MS_ARCH = [{'id': 1, 'type': 'ANDROID', 'os_version': '11', 'name': 'Android 11'}] +MS_ARCH_PAGE = {'items': MS_ARCH, 'total': 1, 'page': 1, 'size': 50, 'pages': 1} MONO_ARCH = [{'id': 1, 'type': 1, 'name': 'Android 11'}] @@ -41,6 +42,13 @@ def test_autodetect_microservices_by_payload(mocked_responses, monkeypatch): assert mocked_responses.calls[0].request.headers['Authorization'] == f'Bearer {TOKEN}' +def test_autodetect_microservices_by_paginated_payload(mocked_responses, monkeypatch): + """STG-4892 wraps the Clark/Scanyon catalogue in a Page envelope.""" + monkeypatch.delenv('MDAST_CLI_MODE', raising=False) + mocked_responses.add(responses.GET, ARCH_URL, json=MS_ARCH_PAGE) + assert resolve_installation_mode(REST_URL, TOKEN, None) == MODE_MICROSERVICES + + def test_autodetect_monolith_by_payload(mocked_responses, monkeypatch): monkeypatch.delenv('MDAST_CLI_MODE', raising=False) # Bearer probe -> monolith answers 401 (Token-only), then Token probe -> int type diff --git a/tests/test_ms_architecture.py b/tests/test_ms_architecture.py index acb6dfa..7e38a80 100644 --- a/tests/test_ms_architecture.py +++ b/tests/test_ms_architecture.py @@ -26,6 +26,21 @@ def test_falls_back_to_first_platform_version(): assert resolve_ms_os_version(architectures, OS_IOS) == '16' +def test_selects_version_from_paginated_scanyon_catalogue(): + architectures = { + 'items': [ + {'type': 'ANDROID', 'os_version': '14', 'name': 'Android 14'}, + {'type': 'ANDROID', 'os_version': '11', 'name': 'Android 11'}, + ], + 'total': 2, + 'page': 1, + 'size': 50, + 'pages': 1, + } + + assert resolve_ms_os_version(architectures, OS_ANDROID) == '11' + + @pytest.mark.parametrize('architectures', [None, {}, [], [{'type': 'ANDROID', 'os_version': ''}]]) def test_missing_platform_version_returns_none(architectures): assert resolve_ms_os_version(architectures, OS_ANDROID) is None diff --git a/tests/test_smoke_flows.py b/tests/test_smoke_flows.py index 20f4785..d978f79 100644 --- a/tests/test_smoke_flows.py +++ b/tests/test_smoke_flows.py @@ -16,10 +16,15 @@ pytestmark = pytest.mark.smoke -def register_ms_happy_path(rsps, apk_md5, with_testcase=True): - rsps.add(responses.GET, f'{REST_URL}/architectures/', json=[ +def register_ms_happy_path(rsps, apk_md5, with_testcase=True, paginated_architectures=False): + architectures = [ {'id': 1, 'type': 'ANDROID', 'os_version': '11', 'name': 'Android 11', 'description': 'API 30'}, - ]) + ] + if paginated_architectures: + architectures = { + 'items': architectures, 'total': 1, 'page': 1, 'size': 50, 'pages': 1, + } + rsps.add(responses.GET, f'{REST_URL}/architectures/', json=architectures) if with_testcase: rsps.add(responses.GET, f'{REST_URL}/testcases/5/', json={'id': 5, 'os': 'ANDROID'}) rsps.add(responses.GET, f'{REST_URL}/engines/', json=[ @@ -102,6 +107,21 @@ def test_ms_full_flow_with_autodetect(mocked_responses, monkeypatch, tmp_path, t assert exit_code == 0 +def test_ms_full_flow_with_paginated_architectures(mocked_responses, monkeypatch, tmp_path, + tmp_apk, apk_md5, no_sleep): + """STG-4892 Page envelope must work through detect, OS selection and create.""" + monkeypatch.delenv('MDAST_CLI_MODE', raising=False) + monkeypatch.chdir(tmp_path) + register_ms_happy_path(mocked_responses, apk_md5, paginated_architectures=True) + exit_code = run_main(monkeypatch, ms_argv(tmp_apk, include_company_id=False)) + assert exit_code == 0 + create_calls = [ + call for call in mocked_responses.calls + if call.request.url == f'{REST_URL}/scans/start/' + ] + assert json.loads(create_calls[0].request.body)['os_version'] == '11' + + def test_ms_manual_flow_stops_scan(mocked_responses, monkeypatch, tmp_path, tmp_apk, apk_md5, no_sleep, ms_mode): """Scan without a test case keeps the manual semantics: wait, stop, expect SUCCESS."""