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
5 changes: 2 additions & 3 deletions .github/workflows/docker-hub-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,13 @@ 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
run: docker push mobilesecurity/mdast_cli:latest

- name: Docker Hub push tagged image

run: docker push mobilesecurity/mdast_cli:2026.8.4

run: docker push mobilesecurity/mdast_cli:2026.8.5

2 changes: 1 addition & 1 deletion mdast_cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '2026.8.4'
__version__ = '2026.8.5'
11 changes: 9 additions & 2 deletions mdast_cli/ms_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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)
Expand Down
35 changes: 28 additions & 7 deletions mdast_cli_core/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)


Expand Down Expand Up @@ -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) '
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.8.4',
version='2026.8.5',

python_requires='>=3.12',

Expand Down
8 changes: 8 additions & 0 deletions tests/test_mode_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'}]


Expand All @@ -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
Expand Down
15 changes: 15 additions & 0 deletions tests/test_ms_architecture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
26 changes: 23 additions & 3 deletions tests/test_smoke_flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=[
Expand Down Expand Up @@ -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."""
Expand Down
Loading