From 8b3e4d5171f9c009e7bbe734d239017bc072ed2b Mon Sep 17 00:00:00 2001 From: Shikha Jha Date: Thu, 24 Sep 2026 18:42:01 +0530 Subject: [PATCH 01/10] added secure build and troubleshoot deployment --- .../cli/command_modules/appservice/_help.py | 29 ++ .../cli/command_modules/appservice/_params.py | 16 ++ .../command_modules/appservice/commands.py | 54 ++++ .../cli/command_modules/appservice/custom.py | 251 +++++++++++++++++- .../latest/test_webapp_commands_thru_mock.py | 187 ++++++++++++- 5 files changed, 534 insertions(+), 3 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_help.py b/src/azure-cli/azure/cli/command_modules/appservice/_help.py index 959164f0628..0abc400915f 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -3612,4 +3612,33 @@ text: az webapp deploy --resource-group ResourceGroup --name AppName --src-path SourcePath --type static --target-path staticfiles/test.txt - name: Deploy a zip file with enriched error diagnostics on failure. text: az webapp deploy -g ResourceGroup -n AppName --src-path app.zip --enriched-errors true + - name: Deploy a Python app and show a Secure Build summary after deployment. + text: az webapp deploy -g ResourceGroup -n AppName --src-path app.zip --show-secure-build +""" + +helps['webapp secure-build'] = """ + type: group + short-summary: Review dependency vulnerability analysis for a web app. +""" + +helps['webapp secure-build show'] = """ + type: command + short-summary: Show the Secure Build report for the active deployment of a Linux web app. + long-summary: The service uses a cached report when available. Use --rescan to request fresh dependency analysis, which can take several minutes. Secure Build currently supports Python dependency information produced by supported platform builds. + examples: + - name: Show the Secure Build report for the active deployment. + text: az webapp secure-build show --resource-group ResourceGroup --name AppName + - name: Run a fresh analysis for a deployment slot and show critical findings. + text: az webapp secure-build show --resource-group ResourceGroup --name AppName --slot staging --rescan --query "findings[?advisory.severity=='CRITICAL']" +""" + +helps['webapp troubleshoot deployment'] = """ + type: command + short-summary: Show the latest deployment state and diagnostic information for a web app. + long-summary: Returns a point-in-time snapshot from Kudu and enriches it with platform build and runtime status when available. The command does not wait for deployment completion. + examples: + - name: Show the latest deployment status for a web app. + text: az webapp troubleshoot deployment --resource-group ResourceGroup --name AppName + - name: Show the latest deployment status for a deployment slot. + text: az webapp troubleshoot deployment --resource-group ResourceGroup --name AppName --slot staging """ diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_params.py b/src/azure-cli/azure/cli/command_modules/appservice/_params.py index 675d17ee4de..e5a299acbc6 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -886,6 +886,20 @@ def load_arguments(self, _): c.argument('instance', options_list=['--instance'], help="Scope the report to a single worker instance. Accepts either the ARM instanceId or the machine name (e.g. `lw0sdlwk0007AB`). When omitted, returns an overview of every instance seen in the last 24 hours.") c.argument('report', options_list=['--report'], arg_type=get_three_state_flag(), help="Print a human-readable, color-coded report to stdout instead of returning the structured payload.") + with self.argument_context('webapp troubleshoot deployment') as c: + c.argument('name', arg_type=webapp_name_arg_type, id_part=None) + c.argument('resource_group_name', arg_type=resource_group_name_type) + c.argument('slot', options_list=['--slot', '-s'], + help='Name of the web app slot. Defaults to the production slot.') + + with self.argument_context('webapp secure-build show') as c: + c.argument('name', arg_type=webapp_name_arg_type, id_part=None) + c.argument('resource_group_name', arg_type=resource_group_name_type) + c.argument('slot', options_list=['--slot', '-s'], + help='Name of the web app slot. Defaults to the production slot.') + c.argument('rescan', options_list=['--rescan'], action='store_true', + help='Run a fresh dependency analysis instead of using a cached report.') + with self.argument_context('webapp troubleshoot collect network-capture') as c: c.argument('name', arg_type=webapp_name_arg_type, id_part=None) c.argument('resource_group', arg_type=resource_group_name_type) @@ -1147,6 +1161,8 @@ def load_arguments(self, _): help='If true, deployment failures will show context-enriched diagnostics with error codes, suggested fixes, and Copilot prompts. Enabled by default; use --enriched-errors false to disable.', arg_type=get_three_state_flag(), default=True) c.argument('tag', help='Linux only. A friendly name used to identify the deployment.') + c.argument('show_secure_build', options_list=['--show-secure-build'], action='store_true', default=False, + help='Show a Secure Build summary after deployment. This option can add several minutes to the command.') with self.argument_context('functionapp deploy') as c: c.argument('name', options_list=['--name', '-n'], help='Name of the function app to deploy to.') diff --git a/src/azure-cli/azure/cli/command_modules/appservice/commands.py b/src/azure-cli/azure/cli/command_modules/appservice/commands.py index d29af8b1d59..96b6a8e9ea8 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/commands.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/commands.py @@ -48,6 +48,54 @@ def transform_runtime_list_output(result): ]) for r in result] +def transform_secure_build_output(result): + from collections import OrderedDict + + if not isinstance(result, dict): + return [] + findings = result.get('findings') or [] + rows = [] + for finding in findings: + if not isinstance(finding, dict): + continue + advisory = finding.get('advisory') or {} + rows.append(OrderedDict([ + ('Package', finding.get('package') or '-'), + ('Version', finding.get('version') or '-'), + ('Severity', advisory.get('severity') or '-'), + ('Advisory', advisory.get('cve') or advisory.get('advisoryId') or '-'), + ('FixedVersion', advisory.get('firstPatchedVersion') or '-'), + ])) + if rows: + return rows + return [OrderedDict([ + ('Package', 'No vulnerabilities found'), + ('Version', '-'), + ('Severity', '-'), + ('Advisory', '-'), + ('FixedVersion', '-'), + ])] + + +def transform_troubleshoot_deployment_output(result): + from collections import OrderedDict + + if not isinstance(result, dict): + return [] + runtime = result.get('runtime') or {} + return [OrderedDict([ + ('DeploymentId', result.get('deploymentId') or '-'), + ('State', result.get('state') or 'Unknown'), + ('InProgress', result.get('inProgress', False)), + ('Complete', result.get('complete', False)), + ('Active', result.get('active', False)), + ('Deployer', result.get('deployer') or '-'), + ('LastDeploymentTime', result.get('lastDeploymentTime') or '-'), + ('InstancesSuccessful', runtime.get('instancesSuccessful', '-')), + ('InstancesFailed', runtime.get('instancesFailed', '-')), + ])] + + def transform_troubleshoot_config_output(result): """Flatten the troubleshoot config payload into a per-setting table. @@ -380,6 +428,12 @@ def load_command_table(self, _): table_transformer=transform_troubleshoot_config_output) g.custom_command('status', 'troubleshoot_status', table_transformer=transform_troubleshoot_status_output) + g.custom_command('deployment', 'troubleshoot_deployment', + table_transformer=transform_troubleshoot_deployment_output) + + with self.command_group('webapp secure-build', is_preview=True) as g: + g.custom_show_command('show', 'show_secure_build_report', + table_transformer=transform_secure_build_output) with self.command_group('webapp troubleshoot collect', is_preview=True) as g: g.custom_command('network-capture', 'collect_network_capture', diff --git a/src/azure-cli/azure/cli/command_modules/appservice/custom.py b/src/azure-cli/azure/cli/command_modules/appservice/custom.py index 9601d262676..5a4976afa65 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -51,7 +51,8 @@ from azure.cli.core.azclierror import (InvalidArgumentValueError, MutuallyExclusiveArgumentError, ResourceNotFoundError, RequiredArgumentMissingError, ValidationError, CLIInternalError, UnclassifiedUserFault, AzureResponseError, AzureInternalError, - ArgumentUsageError, FileOperationError) + ArgumentUsageError, FileOperationError, AzureConnectionError, + BadRequestError, UnauthorizedError, ForbiddenError) from .tunnel import TunnelServer @@ -6874,6 +6875,241 @@ def list_deployment_logs(cmd, resource_group, name, slot=None): return response.json() or [] +_SECURE_BUILD_TIMEOUT_SECONDS = 330 + + +def _request_secure_build_report(cmd, resource_group_name, name, slot=None, rescan=False): + import requests + from azure.cli.core.util import should_disable_connection_verify + + scm_url = _get_scm_url(cmd, resource_group_name, name, slot) + headers = get_scm_site_headers(cmd.cli_ctx, name, resource_group_name, slot) + report_url = '{}/api/securebuild'.format(scm_url) + + try: + response = requests.get( + report_url, + headers=headers, + params={'rescan': str(bool(rescan)).lower()}, + timeout=_SECURE_BUILD_TIMEOUT_SECONDS, + verify=not should_disable_connection_verify()) + except requests.RequestException as ex: + raise AzureConnectionError( + "Failed to connect to the Secure Build endpoint for web app '{}'.".format(name)) from ex + + if response.status_code == 404: + raise ResourceNotFoundError( + 'Secure Build analysis is unavailable for this web app.', + recommendation=( + 'Verify that Secure Build is enabled and that the active deployment contains ' + 'supported Python dependency information.')) + if response.status_code == 400: + raise BadRequestError('The Secure Build service rejected the analysis request.') + if response.status_code == 401: + raise UnauthorizedError('Authentication to the Secure Build endpoint failed.') + if response.status_code == 403: + raise ForbiddenError('Access to the Secure Build endpoint was denied.') + if response.status_code != 200: + raise AzureResponseError( + "Secure Build analysis failed with status code {}.".format(response.status_code)) + + try: + report = response.json() + except ValueError as ex: + raise AzureResponseError('The Secure Build endpoint returned invalid JSON.') from ex + if not isinstance(report, dict): + raise AzureResponseError('The Secure Build endpoint returned an unexpected response.') + return report + + +def show_secure_build_report(cmd, resource_group_name, name, slot=None, rescan=False): + _ensure_linux_webapp( + cmd, resource_group_name, name, slot, + command_label="'az webapp secure-build show'") + return _request_secure_build_report(cmd, resource_group_name, name, slot, rescan) + + +def _secure_build_command(name, resource_group_name, slot=None): + command = 'az webapp secure-build show --name {} --resource-group {}'.format( + name, resource_group_name) + if slot: + command += ' --slot {}'.format(slot) + return command + + +def _show_secure_build_after_deployment(params): + if not params.is_linux_webapp: + logger.warning('Secure Build analysis is currently supported only for Linux web apps.') + return + + command = _secure_build_command( + params.webapp_name, params.resource_group_name, params.slot) + try: + report = _request_secure_build_report( + params.cmd, params.resource_group_name, params.webapp_name, params.slot) + except Exception as ex: # pylint: disable=broad-except + logger.warning( + "Deployment succeeded, but Secure Build analysis could not be retrieved. Run '%s' to try again.", + command) + logger.debug('Secure Build report retrieval failed after deployment: %s', ex, exc_info=True) + return + + summary = report.get('summary') if isinstance(report.get('summary'), dict) else {} + findings = report.get('findings') if isinstance(report.get('findings'), list) else [] + critical_findings = sum( + 1 for finding in findings + if isinstance(finding, dict) and + str((finding.get('advisory') or {}).get('severity', '')).upper() == 'CRITICAL') + logger.warning( + 'Secure Build analysis: %s package(s) assessed, %s vulnerable package(s), ' + '%s vulnerability finding(s), %s critical. Run \'%s\' for the full report.', + summary.get('packagesAssessed', 'unknown'), + summary.get('vulnerablePackages', 'unknown'), + summary.get('vulnerabilitiesFound', len(findings)), + critical_findings, + command) + + +_KUDU_DEPLOYMENT_STATES = { + 0: 'Pending', + 1: 'Building', + 2: 'Deploying', + 3: 'Failed', + 4: 'Succeeded', + 5: 'Cancelled', + 6: 'PartiallySucceeded', +} +_ARM_DEPLOYMENT_IN_PROGRESS_STATES = { + 'BuildRequestReceived', + 'BuildInProgress', + 'BuildSuccessful', + 'RuntimeStarting', +} + + +def _get_arm_deployment_status(cmd, resource_group_name, name, slot, deployment_id): + deployment_status_url = _build_deploymentstatus_url( + cmd, resource_group_name, name, slot, deployment_id) + try: + response = send_raw_request(cmd.cli_ctx, 'GET', deployment_status_url) + body = response.json() + except (HttpResponseError, ValueError) as ex: + logger.debug( + "ARM deployment status is unavailable for deployment '%s': %s", + deployment_id, ex) + return None + except Exception as ex: # pylint: disable=broad-except + logger.debug( + "Unexpected error retrieving ARM deployment status for deployment '%s': %s", + deployment_id, ex, exc_info=True) + return None + + properties = body.get('properties') if isinstance(body, dict) else None + return properties if isinstance(properties, dict) else None + + +def troubleshoot_deployment(cmd, resource_group_name, name, slot=None): + import requests + from azure.cli.core.util import should_disable_connection_verify + + scm_url = _get_scm_url(cmd, resource_group_name, name, slot) + headers = get_scm_site_headers(cmd.cli_ctx, name, resource_group_name, slot) + latest_url = '{}/api/deployments/latest'.format(scm_url) + try: + response = requests.get( + latest_url, + headers=headers, + timeout=30, + verify=not should_disable_connection_verify()) + except requests.RequestException as ex: + raise AzureConnectionError( + "Failed to connect to deployment status for web app '{}'.".format(name)) from ex + + if response.status_code == 404: + return { + 'name': name, + 'resourceGroup': resource_group_name, + 'slot': slot, + 'state': 'NoDeployment', + 'inProgress': False, + 'complete': False, + 'active': False, + 'kudu': {'reachable': True, 'statusCode': 404}, + } + if response.status_code == 401: + raise UnauthorizedError('Authentication to the deployment status endpoint failed.') + if response.status_code == 403: + raise ForbiddenError('Access to the deployment status endpoint was denied.') + if response.status_code not in (200, 202): + raise AzureResponseError( + 'Deployment status retrieval failed with status code {}.'.format(response.status_code)) + + try: + deployment = response.json() + except ValueError as ex: + raise AzureResponseError('The deployment status endpoint returned invalid JSON.') from ex + if not isinstance(deployment, dict): + raise AzureResponseError('The deployment status endpoint returned an unexpected response.') + + deployment_id = deployment.get('id') + kudu_status = deployment.get('status') + complete = bool(deployment.get('complete')) + active = bool(deployment.get('active')) + arm_status = _get_arm_deployment_status( + cmd, resource_group_name, name, slot, deployment_id) if deployment_id else None + runtime_state = arm_status.get('status') if arm_status else None + state = ( + runtime_state or + deployment.get('provisioningState') or + deployment.get('status_text') or + _KUDU_DEPLOYMENT_STATES.get(kudu_status, 'Unknown')) + in_progress = ( + runtime_state in _ARM_DEPLOYMENT_IN_PROGRESS_STATES + if runtime_state else not complete and kudu_status in (0, 1, 2)) + last_deployment_time = ( + deployment.get('end_time') or + deployment.get('start_time') or + deployment.get('received_time')) + + payload = { + 'name': name, + 'resourceGroup': resource_group_name, + 'slot': slot, + 'deploymentId': deployment_id, + 'activeDeploymentId': deployment_id if active else None, + 'state': state, + 'inProgress': in_progress, + 'complete': complete, + 'active': active, + 'lastDeploymentTime': last_deployment_time, + 'receivedTime': deployment.get('received_time'), + 'startTime': deployment.get('start_time'), + 'endTime': deployment.get('end_time'), + 'lastSuccessfulTime': deployment.get('last_success_end_time'), + 'deployer': deployment.get('deployer'), + 'message': deployment.get('message'), + 'progress': deployment.get('progress'), + 'kudu': { + 'reachable': True, + 'statusCode': response.status_code, + 'status': kudu_status, + 'statusText': deployment.get('status_text'), + 'provisioningState': deployment.get('provisioningState'), + 'logUrl': deployment.get('log_url'), + }, + } + if arm_status: + payload['runtime'] = { + 'status': runtime_state, + 'instancesInProgress': arm_status.get('numberOfInstancesInProgress'), + 'instancesSuccessful': arm_status.get('numberOfInstancesSuccessful'), + 'instancesFailed': arm_status.get('numberOfInstancesFailed'), + 'errors': arm_status.get('errors') or [], + 'failedInstancesLogs': arm_status.get('failedInstancesLogs') or [], + } + return payload + + def _ensure_linux_webapp_for_startup_logs(cmd, resource_group, name, slot=None): _ensure_linux_webapp(cmd, resource_group, name, slot, command_label="'az webapp log startup'") @@ -12045,7 +12281,8 @@ def perform_onedeploy_webapp(cmd, track_status=True, enable_kudu_warmup=True, enriched_errors=True, - tag=None): + tag=None, + show_secure_build=False): params = OneDeployParams() params.cmd = cmd @@ -12065,6 +12302,7 @@ def perform_onedeploy_webapp(cmd, params.enable_kudu_warmup = enable_kudu_warmup params.enriched_errors = enriched_errors params.tag = tag + params.show_secure_build = show_secure_build # When a slot is targeted, fetch the slot's Site (not production) so the # cached model matches what every downstream consumer expects — slots have @@ -12112,6 +12350,7 @@ def __init__(self): self.is_functionapp = None self.enriched_errors = True self.tag = None + self.show_secure_build = False # Per-invocation caches. Populated during a single deploy and # cleared in _perform_onedeploy_internal's `finally` block. These MUST # NOT be logged, serialized, or accessed outside the current call @@ -12570,6 +12809,14 @@ def _make_onedeploy_request(params): logger.warning("Deployment has completed successfully") if not (poll_async_deployment_for_debugging and params.track_status): _log_webapp_troubleshoot_status_tip(params.webapp_name, params.resource_group_name, params.is_linux_webapp) + if params.show_secure_build: + if poll_async_deployment_for_debugging: + _show_secure_build_after_deployment(params) + else: + logger.warning( + "Deployment was submitted asynchronously. Run '%s' after it completes to view Secure Build " + "analysis.", + _secure_build_command(params.webapp_name, params.resource_group_name, params.slot)) logger.warning("You can visit your app at: %s", _get_visit_url(params)) return response_body diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py index 317283d8839..1df57b35336 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py @@ -45,8 +45,13 @@ _extract_runtime_error, _log_webapp_troubleshoot_config_tip, troubleshoot_status, + troubleshoot_deployment, + show_secure_build_report, + _show_secure_build_after_deployment, create_webapp) -from azure.cli.command_modules.appservice.commands import transform_troubleshoot_config_output +from azure.cli.command_modules.appservice.commands import (transform_troubleshoot_config_output, + transform_secure_build_output, + transform_troubleshoot_deployment_output) from azure.cli.command_modules.appservice._deployment_context_engine import EnrichedDeploymentError # pylint: disable=line-too-long @@ -65,6 +70,175 @@ def _get_test_cmd(): return cmd +class TestSecureBuildMocked(unittest.TestCase): + + @mock.patch('azure.cli.core.util.should_disable_connection_verify', return_value=False) + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': 'Bearer token'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('azure.cli.command_modules.appservice.custom._ensure_linux_webapp') + @mock.patch('requests.get') + def test_show_secure_build_returns_report(self, requests_get_mock, _ensure_linux_mock, + _scm_url_mock, _headers_mock, _verify_mock): + report = { + 'summary': {'packagesAssessed': 10, 'vulnerablePackages': 1, 'vulnerabilitiesFound': 1}, + 'findings': [{'package': 'sample', 'version': '1.0', 'advisory': { + 'severity': 'CRITICAL', 'cve': 'CVE-2026-0001', 'firstPatchedVersion': '1.1'}}] + } + response = mock.MagicMock(status_code=200) + response.json.return_value = report + requests_get_mock.return_value = response + + result = show_secure_build_report(_get_test_cmd(), 'myRG', 'myApp', slot='staging', rescan=True) + + self.assertEqual(result, report) + requests_get_mock.assert_called_once_with( + 'https://myapp.scm.azurewebsites.net/api/securebuild', + headers={'Authorization': 'Bearer token'}, + params={'rescan': 'true'}, + timeout=330, + verify=True) + + @mock.patch('azure.cli.core.util.should_disable_connection_verify', return_value=False) + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', return_value={}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', return_value='https://scm') + @mock.patch('azure.cli.command_modules.appservice.custom._ensure_linux_webapp') + @mock.patch('requests.get') + def test_show_secure_build_404_is_unavailable(self, requests_get_mock, _ensure_linux_mock, + _scm_url_mock, _headers_mock, _verify_mock): + requests_get_mock.return_value = mock.MagicMock(status_code=404) + + with self.assertRaises(ResourceNotFoundError): + show_secure_build_report(_get_test_cmd(), 'myRG', 'myApp') + + @mock.patch('azure.cli.command_modules.appservice.custom.logger') + @mock.patch('azure.cli.command_modules.appservice.custom._request_secure_build_report') + def test_post_deployment_summary_does_not_raise(self, request_mock, logger_mock): + request_mock.side_effect = AzureResponseError('scan failed') + params = mock.MagicMock( + is_linux_webapp=True, + webapp_name='myApp', + resource_group_name='myRG', + slot=None, + cmd=_get_test_cmd()) + + _show_secure_build_after_deployment(params) + + logger_mock.warning.assert_called_once() + self.assertIn('Deployment succeeded', logger_mock.warning.call_args.args[0]) + + def test_secure_build_table_output(self): + rows = transform_secure_build_output({ + 'findings': [{'package': 'sample', 'version': '1.0', 'advisory': { + 'severity': 'CRITICAL', 'advisoryId': 'GHSA-test', 'firstPatchedVersion': '1.1'}}] + }) + + self.assertEqual(rows[0]['Package'], 'sample') + self.assertEqual(rows[0]['Advisory'], 'GHSA-test') + self.assertEqual(rows[0]['FixedVersion'], '1.1') + + +class TestTroubleshootDeploymentMocked(unittest.TestCase): + + @mock.patch('azure.cli.core.util.should_disable_connection_verify', return_value=False) + @mock.patch('azure.cli.command_modules.appservice.custom._get_arm_deployment_status') + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': 'Bearer token'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('requests.get') + def test_troubleshoot_deployment_combines_kudu_and_arm( + self, requests_get_mock, _scm_url_mock, _headers_mock, arm_status_mock, _verify_mock): + response = mock.MagicMock(status_code=200) + response.json.return_value = { + 'id': 'deployment-1', + 'status': 4, + 'status_text': '', + 'complete': True, + 'active': True, + 'end_time': '2026-09-24T10:56:30Z', + 'deployer': 'OneDeploy', + 'provisioningState': 'Succeeded', + 'log_url': 'https://myapp.scm.azurewebsites.net/api/deployments/deployment-1/log', + } + requests_get_mock.return_value = response + arm_status_mock.return_value = { + 'status': 'RuntimeSuccessful', + 'numberOfInstancesInProgress': 0, + 'numberOfInstancesSuccessful': 2, + 'numberOfInstancesFailed': 0, + } + + result = troubleshoot_deployment(_get_test_cmd(), 'myRG', 'myApp') + + self.assertEqual(result['deploymentId'], 'deployment-1') + self.assertEqual(result['activeDeploymentId'], 'deployment-1') + self.assertEqual(result['state'], 'RuntimeSuccessful') + self.assertFalse(result['inProgress']) + self.assertEqual(result['runtime']['instancesSuccessful'], 2) + requests_get_mock.assert_called_once_with( + 'https://myapp.scm.azurewebsites.net/api/deployments/latest', + headers={'Authorization': 'Bearer token'}, + timeout=30, + verify=True) + + @mock.patch('azure.cli.core.util.should_disable_connection_verify', return_value=False) + @mock.patch('azure.cli.command_modules.appservice.custom._get_arm_deployment_status', return_value=None) + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', return_value={}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', return_value='https://scm') + @mock.patch('requests.get') + def test_troubleshoot_deployment_accepts_in_progress_response( + self, requests_get_mock, _scm_url_mock, _headers_mock, _arm_status_mock, _verify_mock): + response = mock.MagicMock(status_code=202) + response.json.return_value = { + 'id': 'deployment-1', + 'status': 1, + 'status_text': 'Building and Deploying', + 'complete': False, + 'active': False, + 'start_time': '2026-09-24T10:56:30Z', + } + requests_get_mock.return_value = response + + result = troubleshoot_deployment(_get_test_cmd(), 'myRG', 'myApp') + + self.assertEqual(result['state'], 'Building and Deploying') + self.assertTrue(result['inProgress']) + self.assertFalse(result['active']) + self.assertEqual(result['kudu']['statusCode'], 202) + + @mock.patch('azure.cli.core.util.should_disable_connection_verify', return_value=False) + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', return_value={}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', return_value='https://scm') + @mock.patch('requests.get') + def test_troubleshoot_deployment_without_history( + self, requests_get_mock, _scm_url_mock, _headers_mock, _verify_mock): + requests_get_mock.return_value = mock.MagicMock(status_code=404) + + result = troubleshoot_deployment(_get_test_cmd(), 'myRG', 'myApp', slot='staging') + + self.assertEqual(result['state'], 'NoDeployment') + self.assertFalse(result['inProgress']) + self.assertEqual(result['slot'], 'staging') + + def test_troubleshoot_deployment_table_output(self): + rows = transform_troubleshoot_deployment_output({ + 'deploymentId': 'deployment-1', + 'state': 'BuildInProgress', + 'inProgress': True, + 'complete': False, + 'active': False, + 'deployer': 'OneDeploy', + 'lastDeploymentTime': '2026-09-24T10:56:30Z', + 'runtime': {'instancesSuccessful': 1, 'instancesFailed': 0}, + }) + + self.assertEqual(rows[0]['State'], 'BuildInProgress') + self.assertTrue(rows[0]['InProgress']) + self.assertEqual(rows[0]['InstancesSuccessful'], 1) + + class TestTroubleshootConfigDiscovery(unittest.TestCase): @mock.patch('azure.cli.command_modules.appservice.custom.logger') @@ -1659,6 +1833,17 @@ def test_webapp_deploy_ignores_tag_for_windows_webapp(self, site_operation_mock, warning_mock.assert_any_call('--tag is only supported for Linux web apps and will be ignored.') self.assertIsNone(perform_deploy_mock.call_args.args[0].tag) + @mock.patch('azure.cli.command_modules.appservice.custom._perform_onedeploy_internal') + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') + def test_webapp_deploy_passes_secure_build_option(self, site_operation_mock, perform_deploy_mock): + from azure.cli.command_modules.appservice.custom import perform_onedeploy_webapp + site_operation_mock.return_value = mock.MagicMock(kind='app,linux', reserved=True) + + perform_onedeploy_webapp( + mock.MagicMock(), 'myRG', 'myApp', show_secure_build=True) + + self.assertTrue(perform_deploy_mock.call_args.args[0].show_secure_build) + def test_arm_body_includes_tag(self): import json from azure.cli.command_modules.appservice.custom import OneDeployParams, _get_onedeploy_request_body From c2ac8cec847d3cb87ca179a50259da33b604598b Mon Sep 17 00:00:00 2001 From: Shikha Jha Date: Thu, 24 Sep 2026 19:12:26 +0530 Subject: [PATCH 02/10] add Kudu link --- .../cli/command_modules/appservice/custom.py | 23 ++++++++++++--- .../latest/test_webapp_commands_thru_mock.py | 28 ++++++++++++++++++- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/custom.py b/src/azure-cli/azure/cli/command_modules/appservice/custom.py index 5a4976afa65..17d255f18ec 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -6919,6 +6919,7 @@ def _request_secure_build_report(cmd, resource_group_name, name, slot=None, resc raise AzureResponseError('The Secure Build endpoint returned invalid JSON.') from ex if not isinstance(report, dict): raise AzureResponseError('The Secure Build endpoint returned an unexpected response.') + report['kuduUrl'] = report_url return report @@ -6926,7 +6927,10 @@ def show_secure_build_report(cmd, resource_group_name, name, slot=None, rescan=F _ensure_linux_webapp( cmd, resource_group_name, name, slot, command_label="'az webapp secure-build show'") - return _request_secure_build_report(cmd, resource_group_name, name, slot, rescan) + report = _request_secure_build_report(cmd, resource_group_name, name, slot, rescan) + logger.warning('View Secure Build analysis in Kudu: %s', report['kuduUrl']) + logger.warning('Kudu access is required to open this link.') + return report def _secure_build_command(name, resource_group_name, slot=None): @@ -6962,12 +6966,13 @@ def _show_secure_build_after_deployment(params): str((finding.get('advisory') or {}).get('severity', '')).upper() == 'CRITICAL') logger.warning( 'Secure Build analysis: %s package(s) assessed, %s vulnerable package(s), ' - '%s vulnerability finding(s), %s critical. Run \'%s\' for the full report.', + '%s vulnerability finding(s), %s critical. Run \'%s\' for the full report. View in Kudu: %s', summary.get('packagesAssessed', 'unknown'), summary.get('vulnerablePackages', 'unknown'), summary.get('vulnerabilitiesFound', len(findings)), critical_findings, - command) + command, + report['kuduUrl']) _KUDU_DEPLOYMENT_STATES = { @@ -7026,7 +7031,7 @@ def troubleshoot_deployment(cmd, resource_group_name, name, slot=None): "Failed to connect to deployment status for web app '{}'.".format(name)) from ex if response.status_code == 404: - return { + result = { 'name': name, 'resourceGroup': resource_group_name, 'slot': slot, @@ -7034,8 +7039,12 @@ def troubleshoot_deployment(cmd, resource_group_name, name, slot=None): 'inProgress': False, 'complete': False, 'active': False, + 'kuduUrl': '{}/api/deployments'.format(scm_url), 'kudu': {'reachable': True, 'statusCode': 404}, } + logger.warning('View deployments in Kudu: %s', result['kuduUrl']) + logger.warning('Kudu access is required to open this link.') + return result if response.status_code == 401: raise UnauthorizedError('Authentication to the deployment status endpoint failed.') if response.status_code == 403: @@ -7070,6 +7079,9 @@ def troubleshoot_deployment(cmd, resource_group_name, name, slot=None): deployment.get('end_time') or deployment.get('start_time') or deployment.get('received_time')) + kudu_url = '{}/api/deployments'.format(scm_url) + if deployment_id: + kudu_url += '/{}'.format(quote(str(deployment_id), safe='')) payload = { 'name': name, @@ -7077,6 +7089,7 @@ def troubleshoot_deployment(cmd, resource_group_name, name, slot=None): 'slot': slot, 'deploymentId': deployment_id, 'activeDeploymentId': deployment_id if active else None, + 'kuduUrl': kudu_url, 'state': state, 'inProgress': in_progress, 'complete': complete, @@ -7107,6 +7120,8 @@ def troubleshoot_deployment(cmd, resource_group_name, name, slot=None): 'errors': arm_status.get('errors') or [], 'failedInstancesLogs': arm_status.get('failedInstancesLogs') or [], } + logger.warning('View deployment details in Kudu: %s', payload['kuduUrl']) + logger.warning('Kudu access is required to open this link.') return payload diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py index 1df57b35336..2a18353cf9a 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py @@ -92,7 +92,11 @@ def test_show_secure_build_returns_report(self, requests_get_mock, _ensure_linux result = show_secure_build_report(_get_test_cmd(), 'myRG', 'myApp', slot='staging', rescan=True) - self.assertEqual(result, report) + self.assertEqual(result['summary'], report['summary']) + self.assertEqual(result['findings'], report['findings']) + self.assertEqual( + result['kuduUrl'], + 'https://myapp.scm.azurewebsites.net/api/securebuild') requests_get_mock.assert_called_once_with( 'https://myapp.scm.azurewebsites.net/api/securebuild', headers={'Authorization': 'Bearer token'}, @@ -177,6 +181,9 @@ def test_troubleshoot_deployment_combines_kudu_and_arm( self.assertEqual(result['state'], 'RuntimeSuccessful') self.assertFalse(result['inProgress']) self.assertEqual(result['runtime']['instancesSuccessful'], 2) + self.assertEqual( + result['kuduUrl'], + 'https://myapp.scm.azurewebsites.net/api/deployments/deployment-1') requests_get_mock.assert_called_once_with( 'https://myapp.scm.azurewebsites.net/api/deployments/latest', headers={'Authorization': 'Bearer token'}, @@ -207,6 +214,24 @@ def test_troubleshoot_deployment_accepts_in_progress_response( self.assertTrue(result['inProgress']) self.assertFalse(result['active']) self.assertEqual(result['kudu']['statusCode'], 202) + self.assertEqual( + result['kuduUrl'], + 'https://scm/api/deployments/deployment-1') + + @mock.patch('azure.cli.core.util.should_disable_connection_verify', return_value=False) + @mock.patch('azure.cli.command_modules.appservice.custom._get_arm_deployment_status', return_value=None) + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', return_value={}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', return_value='https://scm') + @mock.patch('requests.get') + def test_troubleshoot_deployment_without_id_links_to_deployment_list( + self, requests_get_mock, _scm_url_mock, _headers_mock, _arm_status_mock, _verify_mock): + response = mock.MagicMock(status_code=200) + response.json.return_value = {'status': 4, 'complete': True, 'active': True} + requests_get_mock.return_value = response + + result = troubleshoot_deployment(_get_test_cmd(), 'myRG', 'myApp') + + self.assertEqual(result['kuduUrl'], 'https://scm/api/deployments') @mock.patch('azure.cli.core.util.should_disable_connection_verify', return_value=False) @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', return_value={}) @@ -221,6 +246,7 @@ def test_troubleshoot_deployment_without_history( self.assertEqual(result['state'], 'NoDeployment') self.assertFalse(result['inProgress']) self.assertEqual(result['slot'], 'staging') + self.assertEqual(result['kuduUrl'], 'https://scm/api/deployments') def test_troubleshoot_deployment_table_output(self): rows = transform_troubleshoot_deployment_output({ From c3041de68735e52922e08f0d67c266bfd1c51bac Mon Sep 17 00:00:00 2001 From: Shikha Jha Date: Fri, 25 Sep 2026 13:08:54 +0530 Subject: [PATCH 03/10] incorporate more changes --- .../cli/command_modules/appservice/_help.py | 2 +- .../command_modules/appservice/commands.py | 31 +--- .../cli/command_modules/appservice/custom.py | 137 +++++++++++++++--- .../latest/test_webapp_commands_thru_mock.py | 114 ++++++++++++++- 4 files changed, 235 insertions(+), 49 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_help.py b/src/azure-cli/azure/cli/command_modules/appservice/_help.py index 0abc400915f..468d16d2549 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -3624,7 +3624,7 @@ helps['webapp secure-build show'] = """ type: command short-summary: Show the Secure Build report for the active deployment of a Linux web app. - long-summary: The service uses a cached report when available. Use --rescan to request fresh dependency analysis, which can take several minutes. Secure Build currently supports Python dependency information produced by supported platform builds. + long-summary: "The service uses a cached report when available. Use --rescan to request fresh dependency analysis, which can take several minutes. Table output shows up to 20 findings; use JSON output or the provided Kudu link for the full report. Secure Build currently supports Python dependency information produced by supported platform builds." examples: - name: Show the Secure Build report for the active deployment. text: az webapp secure-build show --resource-group ResourceGroup --name AppName diff --git a/src/azure-cli/azure/cli/command_modules/appservice/commands.py b/src/azure-cli/azure/cli/command_modules/appservice/commands.py index 96b6a8e9ea8..5b0db3b40d7 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/commands.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/commands.py @@ -49,32 +49,11 @@ def transform_runtime_list_output(result): def transform_secure_build_output(result): - from collections import OrderedDict - - if not isinstance(result, dict): - return [] - findings = result.get('findings') or [] - rows = [] - for finding in findings: - if not isinstance(finding, dict): - continue - advisory = finding.get('advisory') or {} - rows.append(OrderedDict([ - ('Package', finding.get('package') or '-'), - ('Version', finding.get('version') or '-'), - ('Severity', advisory.get('severity') or '-'), - ('Advisory', advisory.get('cve') or advisory.get('advisoryId') or '-'), - ('FixedVersion', advisory.get('firstPatchedVersion') or '-'), - ])) - if rows: - return rows - return [OrderedDict([ - ('Package', 'No vulnerabilities found'), - ('Version', '-'), - ('Severity', '-'), - ('Advisory', '-'), - ('FixedVersion', '-'), - ])] + from .custom import _secure_build_finding_rows + rows = _secure_build_finding_rows(result) + for row in rows: + row.pop('Details URL', None) + return rows def transform_troubleshoot_deployment_output(result): diff --git a/src/azure-cli/azure/cli/command_modules/appservice/custom.py b/src/azure-cli/azure/cli/command_modules/appservice/custom.py index 17d255f18ec..3d8a2a3d9fa 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -28,6 +28,7 @@ from cryptography.hazmat.primitives.serialization import pkcs12 from cryptography.hazmat.primitives import hashes from fabric import Connection +from tabulate import tabulate from knack.prompting import prompt_pass, NoTTYException, prompt_y_n from knack.util import CLIError @@ -44,6 +45,7 @@ from azure.cli.core.commands.client_factory import get_mgmt_service_client from azure.cli.core.commands import LongRunningOperation from azure.cli.core.commands.progress import IndeterminateProgressBar +from azure.cli.core.style import Style, print_styled_text from azure.cli.core.util import shell_safe_json_parse, open_page_in_browser, \ ConfiguredDefaultSetter from azure.cli.core.util import get_az_user_agent, send_raw_request, get_file_json @@ -6919,7 +6921,7 @@ def _request_secure_build_report(cmd, resource_group_name, name, slot=None, resc raise AzureResponseError('The Secure Build endpoint returned invalid JSON.') from ex if not isinstance(report, dict): raise AzureResponseError('The Secure Build endpoint returned an unexpected response.') - report['kuduUrl'] = report_url + report['kuduUrl'] = '{}/securebuild'.format(scm_url) return report @@ -6928,8 +6930,13 @@ def show_secure_build_report(cmd, resource_group_name, name, slot=None, rescan=F cmd, resource_group_name, name, slot, command_label="'az webapp secure-build show'") report = _request_secure_build_report(cmd, resource_group_name, name, slot, rescan) - logger.warning('View Secure Build analysis in Kudu: %s', report['kuduUrl']) - logger.warning('Kudu access is required to open this link.') + from azure.cli.core._output import get_output_format + output_format = get_output_format(cmd.cli_ctx) if getattr(cmd.cli_ctx, 'invocation', None) else None + if output_format == 'table': + _render_secure_build_table_report(report) + return [] + _log_secure_build_report_summary(report) + _print_secure_build_kudu_footer(report) return report @@ -6941,6 +6948,112 @@ def _secure_build_command(name, resource_group_name, slot=None): return command +def _log_secure_build_report_summary(report): + summary = report.get('summary') if isinstance(report.get('summary'), dict) else {} + runtime = report.get('runtime') if isinstance(report.get('runtime'), dict) else {} + findings = [finding for finding in report.get('findings') or [] if isinstance(finding, dict)] + runtime_name = ' '.join(str(value) for value in ( + runtime.get('framework'), runtime.get('version')) if value) or 'Unknown' + vulnerability_count = summary.get('vulnerabilitiesFound', len(findings)) + affected_package_count = summary.get('vulnerablePackages', 'Unknown') + + rows = [ + ('Deployment ID', report.get('deploymentId') or 'Unknown'), + ('Runtime', runtime_name), + ('Packages scanned', summary.get('packagesAssessed', 'Unknown')), + ] + if report.get('generatedAtUtc'): + rows.append(('Report generated', report['generatedAtUtc'])) + scan_summary = tabulate(rows, tablefmt='plain', disable_numparse=True) + rule = '-' * 72 + + if findings: + vulnerability_label = '{} critical {}'.format( + vulnerability_count, + 'vulnerability' if vulnerability_count == 1 else 'vulnerabilities') + affected_package_label = '{} affected {}'.format( + affected_package_count, + 'package' if affected_package_count == 1 else 'packages') + print_styled_text([ + (Style.HIGHLIGHT, '\nSecure Build scan completed\n'), + (Style.PRIMARY, '{}\nSummary\nSecure Build found '.format(rule)), + (Style.ERROR, vulnerability_label), + (Style.PRIMARY, ' in {}.\n\n{}\n{}'.format( + affected_package_label, scan_summary, rule)), + ], file=sys.stderr) + else: + print_styled_text([ + (Style.HIGHLIGHT, '\nSecure Build scan completed\n'), + (Style.PRIMARY, '{}\nSummary\n'.format(rule)), + (Style.SUCCESS, 'Secure Build detected no critical vulnerabilities. ' + 'The scanned packages have no matching alerts.'), + (Style.PRIMARY, '\n\n{}\n{}'.format(scan_summary, rule)), + ], file=sys.stderr) + + +def _secure_build_finding_rows(report): + from collections import OrderedDict + + rows = [] + if not isinstance(report, dict): + return rows + for finding in report.get('findings') or []: + if not isinstance(finding, dict): + continue + advisory = finding.get('advisory') or {} + rows.append(OrderedDict([ + ('Component', finding.get('package') or '-'), + ('Installed', finding.get('version') or '-'), + ('Vulnerability', advisory.get('cve') or advisory.get('advisoryId') or '-'), + ('Fixed version', advisory.get('firstPatchedVersion') or '-'), + ('Details URL', advisory.get('detailsUrl')), + ])) + if len(rows) == 20: + break + return rows + + +def _terminal_hyperlink(label, url): + if not url: + return label + return '\x1b]8;;{0}\x1b\\\x1b[4m{1}\x1b[24m\x1b]8;;\x1b\\'.format(url, label) + + +def _print_secure_build_kudu_footer(report, include_rule=False): + prefix = '{}\n'.format('-' * 72) if include_rule else '' + print_styled_text((Style.PRIMARY, '{}Source: GitHub Advisory Database'.format(prefix)), file=sys.stderr) + print_styled_text([ + (Style.PRIMARY, '\nFull report:\n'), + ('\x1b[4m', report['kuduUrl']), + ], file=sys.stderr) + + +def _render_secure_build_table_report(report): + _log_secure_build_report_summary(report) + finding_rows = _secure_build_finding_rows(report) + if finding_rows: + finding_count = len([ + finding for finding in report.get('findings') or [] if isinstance(finding, dict)]) + heading = 'Critical vulnerabilities' + if finding_count > 20: + heading += ' (showing first 20 of {})'.format(finding_count) + else: + heading += ' ({})'.format(finding_count) + links = [] + for row in finding_rows: + details_url = row.pop('Details URL', None) + if details_url: + links.append((row['Vulnerability'], details_url)) + findings_table = tabulate( + finding_rows, headers='keys', tablefmt='simple', disable_numparse=True) + for label, url in links: + findings_table = findings_table.replace(label, _terminal_hyperlink(label, url), 1) + print_styled_text(( + Style.PRIMARY, + '\n{}\n{}'.format(heading, findings_table)), file=sys.stderr) + _print_secure_build_kudu_footer(report, include_rule=bool(finding_rows)) + + def _show_secure_build_after_deployment(params): if not params.is_linux_webapp: logger.warning('Secure Build analysis is currently supported only for Linux web apps.') @@ -6958,21 +7071,9 @@ def _show_secure_build_after_deployment(params): logger.debug('Secure Build report retrieval failed after deployment: %s', ex, exc_info=True) return - summary = report.get('summary') if isinstance(report.get('summary'), dict) else {} - findings = report.get('findings') if isinstance(report.get('findings'), list) else [] - critical_findings = sum( - 1 for finding in findings - if isinstance(finding, dict) and - str((finding.get('advisory') or {}).get('severity', '')).upper() == 'CRITICAL') - logger.warning( - 'Secure Build analysis: %s package(s) assessed, %s vulnerable package(s), ' - '%s vulnerability finding(s), %s critical. Run \'%s\' for the full report. View in Kudu: %s', - summary.get('packagesAssessed', 'unknown'), - summary.get('vulnerablePackages', 'unknown'), - summary.get('vulnerabilitiesFound', len(findings)), - critical_findings, - command, - report['kuduUrl']) + logger.warning("Run '%s' to view findings in the CLI.", command) + _log_secure_build_report_summary(report) + _print_secure_build_kudu_footer(report) _KUDU_DEPLOYMENT_STATES = { diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py index 2a18353cf9a..e039a14fa61 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py @@ -47,6 +47,9 @@ troubleshoot_status, troubleshoot_deployment, show_secure_build_report, + _log_secure_build_report_summary, + _render_secure_build_table_report, + _terminal_hyperlink, _show_secure_build_after_deployment, create_webapp) from azure.cli.command_modules.appservice.commands import (transform_troubleshoot_config_output, @@ -96,7 +99,7 @@ def test_show_secure_build_returns_report(self, requests_get_mock, _ensure_linux self.assertEqual(result['findings'], report['findings']) self.assertEqual( result['kuduUrl'], - 'https://myapp.scm.azurewebsites.net/api/securebuild') + 'https://myapp.scm.azurewebsites.net/securebuild') requests_get_mock.assert_called_once_with( 'https://myapp.scm.azurewebsites.net/api/securebuild', headers={'Authorization': 'Bearer token'}, @@ -138,9 +141,112 @@ def test_secure_build_table_output(self): 'severity': 'CRITICAL', 'advisoryId': 'GHSA-test', 'firstPatchedVersion': '1.1'}}] }) - self.assertEqual(rows[0]['Package'], 'sample') - self.assertEqual(rows[0]['Advisory'], 'GHSA-test') - self.assertEqual(rows[0]['FixedVersion'], '1.1') + self.assertEqual(rows[0]['Component'], 'sample') + self.assertEqual(rows[0]['Vulnerability'], 'GHSA-test') + self.assertEqual(rows[0]['Fixed version'], '1.1') + self.assertNotIn('Severity', rows[0]) + + def test_secure_build_table_output_is_limited_to_twenty_findings(self): + findings = [ + {'package': 'sample-{}'.format(index), 'version': '1.0', 'advisory': {}} + for index in range(25) + ] + + rows = transform_secure_build_output({'findings': findings}) + + self.assertEqual(len(rows), 20) + self.assertEqual(rows[-1]['Component'], 'sample-19') + + def test_secure_build_table_output_is_empty_without_findings(self): + self.assertEqual(transform_secure_build_output({'findings': []}), []) + + @mock.patch('azure.cli.command_modules.appservice.custom.print_styled_text') + def test_secure_build_summary_includes_scan_context(self, print_styled_text_mock): + _log_secure_build_report_summary({ + 'deploymentId': 'deployment-1', + 'generatedAtUtc': '2026-09-25T10:00:00Z', + 'runtime': {'framework': 'PYTHON', 'version': '3.13'}, + 'summary': { + 'packagesAssessed': 9, + 'vulnerabilitiesFound': 2, + 'vulnerablePackages': 1, + }, + 'findings': [{}, {}], + 'kuduUrl': 'https://myapp.scm.azurewebsites.net/securebuild', + }) + + summary_parts = print_styled_text_mock.call_args.args[0] + summary_text = ''.join(part[1] for part in summary_parts) + self.assertEqual(summary_parts[0][0].value, 'highlight') + self.assertEqual(summary_parts[2][0].value, 'error') + self.assertIn('Secure Build scan completed', summary_text) + self.assertIn('Summary\nSecure Build found 2 critical vulnerabilities in 1 affected package.', summary_text) + self.assertNotIn('Summary\n Secure Build', summary_text) + self.assertIn('Deployment ID', summary_text) + self.assertIn('deployment-1', summary_text) + self.assertIn('Runtime', summary_text) + self.assertIn('PYTHON 3.13', summary_text) + self.assertIn('Packages scanned', summary_text) + self.assertIn('Report generated', summary_text) + self.assertIn('2026-09-25T10:00:00Z', summary_text) + + @mock.patch('azure.cli.command_modules.appservice.custom.print_styled_text') + def test_secure_build_table_report_reports_truncated_findings(self, print_styled_text_mock): + _render_secure_build_table_report({ + 'findings': [{} for _ in range(38)], + 'kuduUrl': 'https://myapp.scm.azurewebsites.net/securebuild', + }) + + _, findings_section = print_styled_text_mock.call_args_list[1].args[0] + self.assertIn('Critical vulnerabilities (showing first 20 of 38)', findings_section) + + @mock.patch('azure.cli.command_modules.appservice.custom.print_styled_text') + def test_secure_build_summary_styles_clean_result_as_success(self, print_styled_text_mock): + _log_secure_build_report_summary({ + 'findings': [], + 'kuduUrl': 'https://myapp.scm.azurewebsites.net/securebuild', + }) + + summary_parts = print_styled_text_mock.call_args.args[0] + summary_text = ''.join(part[1] for part in summary_parts) + self.assertEqual(summary_parts[2][0].value, 'success') + self.assertEqual( + summary_parts[2][1], + 'Secure Build detected no critical vulnerabilities. ' + 'The scanned packages have no matching alerts.') + self.assertIn('Summary\nSecure Build detected no critical vulnerabilities.', summary_text) + self.assertNotIn('Summary\n Secure Build', summary_text) + + @mock.patch('azure.cli.command_modules.appservice.custom.print_styled_text') + def test_secure_build_table_report_ends_with_kudu_link(self, print_styled_text_mock): + _render_secure_build_table_report({ + 'findings': [{'package': 'sample', 'version': '1.0', 'advisory': { + 'severity': 'CRITICAL', 'advisoryId': 'GHSA-test', + 'detailsUrl': 'https://github.com/advisories/GHSA-test'}}], + 'kuduUrl': 'https://myapp.scm.azurewebsites.net/securebuild', + }) + + _, findings_section = print_styled_text_mock.call_args_list[1].args[0] + self.assertIn('Critical vulnerabilities (1)', findings_section) + self.assertNotIn('showing first', findings_section) + self.assertIn('\x1b]8;;https://github.com/advisories/GHSA-test\x1b\\', findings_section) + self.assertIn('\x1b[4mGHSA-test\x1b[24m', findings_section) + _, source = print_styled_text_mock.call_args_list[-2].args[0] + self.assertIn('Source: GitHub Advisory Database', source) + self.assertNotIn('critical vulnerabilities only', source) + self.assertNotIn('Kudu access is required', source) + footer = print_styled_text_mock.call_args_list[-1].args[0] + self.assertEqual(footer[0][1], '\nFull report:\n') + self.assertNotIn('Ctrl+click', footer[0][1]) + self.assertEqual(footer[-1][0], '\x1b[4m') + self.assertEqual(footer[-1][1], 'https://myapp.scm.azurewebsites.net/securebuild') + + def test_secure_build_vulnerability_uses_terminal_hyperlink(self): + link = _terminal_hyperlink('CVE-2026-0001', 'https://github.com/advisories/GHSA-test') + + self.assertIn('\x1b]8;;https://github.com/advisories/GHSA-test\x1b\\', link) + self.assertIn('\x1b[4mCVE-2026-0001\x1b[24m', link) + self.assertTrue(link.endswith('\x1b]8;;\x1b\\')) class TestTroubleshootDeploymentMocked(unittest.TestCase): From 4e00c937a5bfaad61559ed8837b5605d152440dc Mon Sep 17 00:00:00 2001 From: Shikha Jha Date: Fri, 25 Sep 2026 18:04:28 +0530 Subject: [PATCH 04/10] more changes --- .../azure/cli/command_modules/appservice/commands.py | 7 ++----- .../tests/latest/test_webapp_commands_thru_mock.py | 7 +++++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/commands.py b/src/azure-cli/azure/cli/command_modules/appservice/commands.py index 5b0db3b40d7..7161830b808 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/commands.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/commands.py @@ -65,13 +65,10 @@ def transform_troubleshoot_deployment_output(result): return [OrderedDict([ ('DeploymentId', result.get('deploymentId') or '-'), ('State', result.get('state') or 'Unknown'), - ('InProgress', result.get('inProgress', False)), - ('Complete', result.get('complete', False)), ('Active', result.get('active', False)), - ('Deployer', result.get('deployer') or '-'), + ('Succeeded', runtime.get('instancesSuccessful', '-')), + ('Failed', runtime.get('instancesFailed', '-')), ('LastDeploymentTime', result.get('lastDeploymentTime') or '-'), - ('InstancesSuccessful', runtime.get('instancesSuccessful', '-')), - ('InstancesFailed', runtime.get('instancesFailed', '-')), ])] diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py index e039a14fa61..7bcc4234efd 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py @@ -367,8 +367,11 @@ def test_troubleshoot_deployment_table_output(self): }) self.assertEqual(rows[0]['State'], 'BuildInProgress') - self.assertTrue(rows[0]['InProgress']) - self.assertEqual(rows[0]['InstancesSuccessful'], 1) + self.assertFalse(rows[0]['Active']) + self.assertEqual(rows[0]['Succeeded'], 1) + self.assertEqual(rows[0]['Failed'], 0) + self.assertEqual(list(rows[0]), [ + 'DeploymentId', 'State', 'Active', 'Succeeded', 'Failed', 'LastDeploymentTime']) class TestTroubleshootConfigDiscovery(unittest.TestCase): From 18b4beb467407a602125e76c28977e548a8e0cb4 Mon Sep 17 00:00:00 2001 From: Shikha Jha Date: Fri, 25 Sep 2026 22:41:41 +0530 Subject: [PATCH 05/10] fix render format --- .../cli/command_modules/appservice/_help.py | 10 +- .../cli/command_modules/appservice/_params.py | 3 +- .../command_modules/appservice/commands.py | 6 +- .../cli/command_modules/appservice/custom.py | 81 ++++++----- .../latest/test_webapp_commands_thru_mock.py | 133 +++++++++++++----- 5 files changed, 154 insertions(+), 79 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_help.py b/src/azure-cli/azure/cli/command_modules/appservice/_help.py index 468d16d2549..b559de36f57 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -3612,24 +3612,24 @@ text: az webapp deploy --resource-group ResourceGroup --name AppName --src-path SourcePath --type static --target-path staticfiles/test.txt - name: Deploy a zip file with enriched error diagnostics on failure. text: az webapp deploy -g ResourceGroup -n AppName --src-path app.zip --enriched-errors true - - name: Deploy a Python app and show a Secure Build summary after deployment. + - name: Deploy a Python app and show a Secure Build summary and full-report link. text: az webapp deploy -g ResourceGroup -n AppName --src-path app.zip --show-secure-build """ helps['webapp secure-build'] = """ type: group - short-summary: Review dependency vulnerability analysis for a web app. + short-summary: Review open source vulnerabilities in a web app's packages. """ helps['webapp secure-build show'] = """ type: command short-summary: Show the Secure Build report for the active deployment of a Linux web app. - long-summary: "The service uses a cached report when available. Use --rescan to request fresh dependency analysis, which can take several minutes. Table output shows up to 20 findings; use JSON output or the provided Kudu link for the full report. Secure Build currently supports Python dependency information produced by supported platform builds." + long-summary: "The service uses a cached report when available. Use --rescan to request fresh dependency analysis, which can take several minutes. Default table output shows a human-readable summary and up to 20 findings. Standard --query processing remains available and controls the resulting table shape. Use JSON output or the provided Kudu link for the full report. Secure Build currently supports Python dependency information produced by supported platform builds." examples: - name: Show the Secure Build report for the active deployment. - text: az webapp secure-build show --resource-group ResourceGroup --name AppName + text: az webapp secure-build show --resource-group ResourceGroup --name AppName --output table - name: Run a fresh analysis for a deployment slot and show critical findings. - text: az webapp secure-build show --resource-group ResourceGroup --name AppName --slot staging --rescan --query "findings[?advisory.severity=='CRITICAL']" + text: az webapp secure-build show --resource-group ResourceGroup --name AppName --slot staging --rescan --query "findings[?advisory.severity=='CRITICAL']" --output table """ helps['webapp troubleshoot deployment'] = """ diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_params.py b/src/azure-cli/azure/cli/command_modules/appservice/_params.py index e5a299acbc6..eebe4caf32e 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -1162,7 +1162,8 @@ def load_arguments(self, _): arg_type=get_three_state_flag(), default=True) c.argument('tag', help='Linux only. A friendly name used to identify the deployment.') c.argument('show_secure_build', options_list=['--show-secure-build'], action='store_true', default=False, - help='Show a Secure Build summary after deployment. This option can add several minutes to the command.') + help='Show a Secure Build summary and full-report link after deployment. ' + 'This option can add several minutes to the command.') with self.argument_context('functionapp deploy') as c: c.argument('name', options_list=['--name', '-n'], help='Name of the function app to deploy to.') diff --git a/src/azure-cli/azure/cli/command_modules/appservice/commands.py b/src/azure-cli/azure/cli/command_modules/appservice/commands.py index 7161830b808..3f14d6735b0 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/commands.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/commands.py @@ -49,7 +49,11 @@ def transform_runtime_list_output(result): def transform_secure_build_output(result): - from .custom import _secure_build_finding_rows + import sys + from .custom import _render_secure_build_table_report, _secure_build_finding_rows + if isinstance(result, dict): + _render_secure_build_table_report(result, file=sys.stdout) + return [] rows = _secure_build_finding_rows(result) for row in rows: row.pop('Details URL', None) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/custom.py b/src/azure-cli/azure/cli/command_modules/appservice/custom.py index 3d8a2a3d9fa..6626c16d96b 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -45,7 +45,7 @@ from azure.cli.core.commands.client_factory import get_mgmt_service_client from azure.cli.core.commands import LongRunningOperation from azure.cli.core.commands.progress import IndeterminateProgressBar -from azure.cli.core.style import Style, print_styled_text +from azure.cli.core.style import Style, format_styled_text, print_styled_text from azure.cli.core.util import shell_safe_json_parse, open_page_in_browser, \ ConfiguredDefaultSetter from azure.cli.core.util import get_az_user_agent, send_raw_request, get_file_json @@ -6929,15 +6929,7 @@ def show_secure_build_report(cmd, resource_group_name, name, slot=None, rescan=F _ensure_linux_webapp( cmd, resource_group_name, name, slot, command_label="'az webapp secure-build show'") - report = _request_secure_build_report(cmd, resource_group_name, name, slot, rescan) - from azure.cli.core._output import get_output_format - output_format = get_output_format(cmd.cli_ctx) if getattr(cmd.cli_ctx, 'invocation', None) else None - if output_format == 'table': - _render_secure_build_table_report(report) - return [] - _log_secure_build_report_summary(report) - _print_secure_build_kudu_footer(report) - return report + return _request_secure_build_report(cmd, resource_group_name, name, slot, rescan) def _secure_build_command(name, resource_group_name, slot=None): @@ -6948,7 +6940,10 @@ def _secure_build_command(name, resource_group_name, slot=None): return command -def _log_secure_build_report_summary(report): +def _log_secure_build_report_summary(report, file=None): + from collections import OrderedDict + + output_file = file or sys.stderr summary = report.get('summary') if isinstance(report.get('summary'), dict) else {} runtime = report.get('runtime') if isinstance(report.get('runtime'), dict) else {} findings = [finding for finding in report.get('findings') or [] if isinstance(finding, dict)] @@ -6957,15 +6952,15 @@ def _log_secure_build_report_summary(report): vulnerability_count = summary.get('vulnerabilitiesFound', len(findings)) affected_package_count = summary.get('vulnerablePackages', 'Unknown') - rows = [ + scan_context = OrderedDict([ ('Deployment ID', report.get('deploymentId') or 'Unknown'), ('Runtime', runtime_name), ('Packages scanned', summary.get('packagesAssessed', 'Unknown')), - ] + ]) if report.get('generatedAtUtc'): - rows.append(('Report generated', report['generatedAtUtc'])) - scan_summary = tabulate(rows, tablefmt='plain', disable_numparse=True) - rule = '-' * 72 + scan_context['Report generated'] = report['generatedAtUtc'] + scan_summary = tabulate( + [scan_context], headers='keys', tablefmt='simple', disable_numparse=True) if findings: vulnerability_label = '{} critical {}'.format( @@ -6975,29 +6970,31 @@ def _log_secure_build_report_summary(report): affected_package_count, 'package' if affected_package_count == 1 else 'packages') print_styled_text([ - (Style.HIGHLIGHT, '\nSecure Build scan completed\n'), - (Style.PRIMARY, '{}\nSummary\nSecure Build found '.format(rule)), + (Style.HIGHLIGHT, '\nSecure Build: Open source vulnerabilities in app packages\n'), + (Style.PRIMARY, 'Secure Build found '), (Style.ERROR, vulnerability_label), - (Style.PRIMARY, ' in {}.\n\n{}\n{}'.format( - affected_package_label, scan_summary, rule)), - ], file=sys.stderr) + (Style.PRIMARY, ' in {}.\n\n{}'.format(affected_package_label, scan_summary)), + ], file=output_file) else: print_styled_text([ - (Style.HIGHLIGHT, '\nSecure Build scan completed\n'), - (Style.PRIMARY, '{}\nSummary\n'.format(rule)), + (Style.HIGHLIGHT, '\nSecure Build: Open source vulnerabilities in app packages\n'), (Style.SUCCESS, 'Secure Build detected no critical vulnerabilities. ' 'The scanned packages have no matching alerts.'), - (Style.PRIMARY, '\n\n{}\n{}'.format(scan_summary, rule)), - ], file=sys.stderr) + (Style.PRIMARY, '\n\n{}'.format(scan_summary)), + ], file=output_file) def _secure_build_finding_rows(report): from collections import OrderedDict rows = [] - if not isinstance(report, dict): + if isinstance(report, dict): + findings = report.get('findings') or [] + elif isinstance(report, list): + findings = report + else: return rows - for finding in report.get('findings') or []: + for finding in findings: if not isinstance(finding, dict): continue advisory = finding.get('advisory') or {} @@ -7016,20 +7013,26 @@ def _secure_build_finding_rows(report): def _terminal_hyperlink(label, url): if not url: return label - return '\x1b]8;;{0}\x1b\\\x1b[4m{1}\x1b[24m\x1b]8;;\x1b\\'.format(url, label) + styled_label = format_styled_text((Style.HYPERLINK, label)) + return '\x1b]8;;{0}\x1b\\{1}\x1b]8;;\x1b\\'.format(url, styled_label) -def _print_secure_build_kudu_footer(report, include_rule=False): - prefix = '{}\n'.format('-' * 72) if include_rule else '' - print_styled_text((Style.PRIMARY, '{}Source: GitHub Advisory Database'.format(prefix)), file=sys.stderr) +def _print_secure_build_kudu_footer(report, file=None): + output_file = file or sys.stderr + print_styled_text([ + (Style.PRIMARY, '\nSource: '), + (Style.HYPERLINK, 'https://github.com/advisories'), + (Style.PRIMARY, ' (Critical vulnerabilities only).'), + ], file=output_file) print_styled_text([ - (Style.PRIMARY, '\nFull report:\n'), - ('\x1b[4m', report['kuduUrl']), - ], file=sys.stderr) + (Style.PRIMARY, '\nTo view the full report, visit: '), + (Style.HYPERLINK, report['kuduUrl']), + ], file=output_file) -def _render_secure_build_table_report(report): - _log_secure_build_report_summary(report) +def _render_secure_build_table_report(report, file=None): + output_file = file or sys.stderr + _log_secure_build_report_summary(report, file=output_file) finding_rows = _secure_build_finding_rows(report) if finding_rows: finding_count = len([ @@ -7050,8 +7053,8 @@ def _render_secure_build_table_report(report): findings_table = findings_table.replace(label, _terminal_hyperlink(label, url), 1) print_styled_text(( Style.PRIMARY, - '\n{}\n{}'.format(heading, findings_table)), file=sys.stderr) - _print_secure_build_kudu_footer(report, include_rule=bool(finding_rows)) + '\n{}\n{}'.format(heading, findings_table)), file=output_file) + _print_secure_build_kudu_footer(report, file=output_file) def _show_secure_build_after_deployment(params): @@ -7071,7 +7074,7 @@ def _show_secure_build_after_deployment(params): logger.debug('Secure Build report retrieval failed after deployment: %s', ex, exc_info=True) return - logger.warning("Run '%s' to view findings in the CLI.", command) + logger.warning("Run '%s' to view findings from this report in the CLI.", command) _log_secure_build_report_summary(report) _print_secure_build_kudu_footer(report) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py index 7bcc4234efd..fb429b8d6e5 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py @@ -5,13 +5,15 @@ import unittest from unittest import mock import os +import sys import types from collections.abc import Mapping from azure.core.exceptions import HttpResponseError from azure.mgmt.web import WebSiteManagementClient -from knack.util import CLIError +from knack.output import format_table +from knack.util import CLIError, CommandResultItem from azure.cli.core.azclierror import (InvalidArgumentValueError, MutuallyExclusiveArgumentError, ArgumentUsageError, @@ -135,16 +137,38 @@ def test_post_deployment_summary_does_not_raise(self, request_mock, logger_mock) logger_mock.warning.assert_called_once() self.assertIn('Deployment succeeded', logger_mock.warning.call_args.args[0]) - def test_secure_build_table_output(self): - rows = transform_secure_build_output({ + @mock.patch('azure.cli.command_modules.appservice.custom._print_secure_build_kudu_footer') + @mock.patch('azure.cli.command_modules.appservice.custom._log_secure_build_report_summary') + @mock.patch('azure.cli.command_modules.appservice.custom.logger') + @mock.patch('azure.cli.command_modules.appservice.custom._request_secure_build_report') + def test_post_deployment_shows_summary_and_link_for_same_report( + self, request_mock, logger_mock, summary_mock, footer_mock): + report = {'summary': {'vulnerabilitiesFound': 1}, 'kuduUrl': 'https://scm/securebuild'} + request_mock.return_value = report + params = mock.MagicMock( + is_linux_webapp=True, + webapp_name='myApp', + resource_group_name='myRG', + slot=None, + cmd=_get_test_cmd()) + + _show_secure_build_after_deployment(params) + + summary_mock.assert_called_once_with(report) + footer_mock.assert_called_once_with(report) + self.assertIn('view findings from this report', logger_mock.warning.call_args.args[0]) + + @mock.patch('azure.cli.command_modules.appservice.custom._render_secure_build_table_report') + def test_secure_build_table_output_renders_full_report(self, render_mock): + report = { 'findings': [{'package': 'sample', 'version': '1.0', 'advisory': { 'severity': 'CRITICAL', 'advisoryId': 'GHSA-test', 'firstPatchedVersion': '1.1'}}] - }) + } - self.assertEqual(rows[0]['Component'], 'sample') - self.assertEqual(rows[0]['Vulnerability'], 'GHSA-test') - self.assertEqual(rows[0]['Fixed version'], '1.1') - self.assertNotIn('Severity', rows[0]) + rows = transform_secure_build_output(report) + + self.assertEqual(rows, []) + render_mock.assert_called_once_with(report, file=sys.stdout) def test_secure_build_table_output_is_limited_to_twenty_findings(self): findings = [ @@ -152,13 +176,41 @@ def test_secure_build_table_output_is_limited_to_twenty_findings(self): for index in range(25) ] - rows = transform_secure_build_output({'findings': findings}) + rows = transform_secure_build_output(findings) self.assertEqual(len(rows), 20) self.assertEqual(rows[-1]['Component'], 'sample-19') def test_secure_build_table_output_is_empty_without_findings(self): - self.assertEqual(transform_secure_build_output({'findings': []}), []) + self.assertEqual(transform_secure_build_output([]), []) + + def test_secure_build_table_output_accepts_queried_findings(self): + rows = transform_secure_build_output([ + {'package': 'sample', 'version': '1.0', 'advisory': { + 'severity': 'CRITICAL', 'advisoryId': 'GHSA-test', + 'firstPatchedVersion': '1.1'}} + ]) + + self.assertEqual(rows[0]['Component'], 'sample') + self.assertEqual(rows[0]['Vulnerability'], 'GHSA-test') + + def test_secure_build_query_active_table_skips_rich_transformer(self): + transformer = mock.MagicMock(side_effect=AssertionError( + 'The table transformer must not run after --query.')) + queried_findings = [ + {'package': 'sample', 'version': '1.0', 'advisory': { + 'severity': 'CRITICAL', 'advisoryId': 'GHSA-test'}} + ] + + output = format_table(CommandResultItem( + queried_findings, + table_transformer=transformer, + is_query_active=True)) + + transformer.assert_not_called() + self.assertIn('Package', output) + self.assertIn('sample', output) + self.assertNotIn('Secure Build:', output) @mock.patch('azure.cli.command_modules.appservice.custom.print_styled_text') def test_secure_build_summary_includes_scan_context(self, print_styled_text_mock): @@ -179,9 +231,10 @@ def test_secure_build_summary_includes_scan_context(self, print_styled_text_mock summary_text = ''.join(part[1] for part in summary_parts) self.assertEqual(summary_parts[0][0].value, 'highlight') self.assertEqual(summary_parts[2][0].value, 'error') - self.assertIn('Secure Build scan completed', summary_text) - self.assertIn('Summary\nSecure Build found 2 critical vulnerabilities in 1 affected package.', summary_text) - self.assertNotIn('Summary\n Secure Build', summary_text) + self.assertIn('Secure Build: Open source vulnerabilities in app packages', summary_text) + self.assertIn('Secure Build found 2 critical vulnerabilities in 1 affected package.', summary_text) + self.assertNotIn('scan completed', summary_text) + self.assertNotIn('Summary', summary_text) self.assertIn('Deployment ID', summary_text) self.assertIn('deployment-1', summary_text) self.assertIn('Runtime', summary_text) @@ -189,6 +242,7 @@ def test_secure_build_summary_includes_scan_context(self, print_styled_text_mock self.assertIn('Packages scanned', summary_text) self.assertIn('Report generated', summary_text) self.assertIn('2026-09-25T10:00:00Z', summary_text) + self.assertNotIn('-' * 72, summary_text) @mock.patch('azure.cli.command_modules.appservice.custom.print_styled_text') def test_secure_build_table_report_reports_truncated_findings(self, print_styled_text_mock): @@ -209,13 +263,15 @@ def test_secure_build_summary_styles_clean_result_as_success(self, print_styled_ summary_parts = print_styled_text_mock.call_args.args[0] summary_text = ''.join(part[1] for part in summary_parts) - self.assertEqual(summary_parts[2][0].value, 'success') + success_parts = [part for part in summary_parts if part[0].value == 'success'] + self.assertEqual(len(success_parts), 1) self.assertEqual( - summary_parts[2][1], + success_parts[0][1], 'Secure Build detected no critical vulnerabilities. ' 'The scanned packages have no matching alerts.') - self.assertIn('Summary\nSecure Build detected no critical vulnerabilities.', summary_text) - self.assertNotIn('Summary\n Secure Build', summary_text) + self.assertIn('Secure Build: Open source vulnerabilities in app packages', summary_text) + self.assertNotIn('scan completed', summary_text) + self.assertNotIn('Summary', summary_text) @mock.patch('azure.cli.command_modules.appservice.custom.print_styled_text') def test_secure_build_table_report_ends_with_kudu_link(self, print_styled_text_mock): @@ -227,25 +283,36 @@ def test_secure_build_table_report_ends_with_kudu_link(self, print_styled_text_m }) _, findings_section = print_styled_text_mock.call_args_list[1].args[0] - self.assertIn('Critical vulnerabilities (1)', findings_section) + self.assertIn('Critical vulnerabilities (1)\nComponent', findings_section) self.assertNotIn('showing first', findings_section) + self.assertIn('GHSA-test', findings_section) self.assertIn('\x1b]8;;https://github.com/advisories/GHSA-test\x1b\\', findings_section) - self.assertIn('\x1b[4mGHSA-test\x1b[24m', findings_section) - _, source = print_styled_text_mock.call_args_list[-2].args[0] - self.assertIn('Source: GitHub Advisory Database', source) - self.assertNotIn('critical vulnerabilities only', source) - self.assertNotIn('Kudu access is required', source) - footer = print_styled_text_mock.call_args_list[-1].args[0] - self.assertEqual(footer[0][1], '\nFull report:\n') - self.assertNotIn('Ctrl+click', footer[0][1]) - self.assertEqual(footer[-1][0], '\x1b[4m') - self.assertEqual(footer[-1][1], 'https://myapp.scm.azurewebsites.net/securebuild') - - def test_secure_build_vulnerability_uses_terminal_hyperlink(self): - link = _terminal_hyperlink('CVE-2026-0001', 'https://github.com/advisories/GHSA-test') - + self.assertNotIn('\x1b[4m', findings_section) + source_parts = print_styled_text_mock.call_args_list[-2].args[0] + source_text = ''.join(part[1] for part in source_parts) + self.assertIn('Source: https://github.com/advisories', source_text) + self.assertIn(' (Critical vulnerabilities only).', source_text) + self.assertNotIn('\x1b]8;;', source_text) + self.assertNotIn('Kudu access is required', source_text) + footer_parts = print_styled_text_mock.call_args_list[-1].args[0] + footer_text = ''.join(part[1] for part in footer_parts) + self.assertIn('\nTo view the full report, visit: ', footer_text) + self.assertIn('https://myapp.scm.azurewebsites.net/securebuild', footer_text) + self.assertNotIn('\x1b]8;;', footer_text) + + @mock.patch('azure.cli.command_modules.appservice.custom.format_styled_text', + return_value='CVE-2026-0001') + def test_secure_build_vulnerability_uses_terminal_hyperlink( + self, format_styled_text_mock): + link = _terminal_hyperlink( + 'CVE-2026-0001', 'https://github.com/advisories/GHSA-test') + + style, label = format_styled_text_mock.call_args.args[0] + self.assertEqual(style.value, 'hyperlink') + self.assertEqual(label, 'CVE-2026-0001') self.assertIn('\x1b]8;;https://github.com/advisories/GHSA-test\x1b\\', link) - self.assertIn('\x1b[4mCVE-2026-0001\x1b[24m', link) + self.assertIn('CVE-2026-0001', link) + self.assertNotIn('\x1b[4m', link) self.assertTrue(link.endswith('\x1b]8;;\x1b\\')) From 55ffb2e0d7041ca6dafdd409cbef4e7dc418a4ce Mon Sep 17 00:00:00 2001 From: Shikha Jha Date: Fri, 25 Sep 2026 23:08:00 +0530 Subject: [PATCH 06/10] minor change --- src/azure-cli/azure/cli/command_modules/appservice/custom.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/custom.py b/src/azure-cli/azure/cli/command_modules/appservice/custom.py index 6626c16d96b..9133e0661fe 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -7147,7 +7147,6 @@ def troubleshoot_deployment(cmd, resource_group_name, name, slot=None): 'kudu': {'reachable': True, 'statusCode': 404}, } logger.warning('View deployments in Kudu: %s', result['kuduUrl']) - logger.warning('Kudu access is required to open this link.') return result if response.status_code == 401: raise UnauthorizedError('Authentication to the deployment status endpoint failed.') @@ -7225,7 +7224,6 @@ def troubleshoot_deployment(cmd, resource_group_name, name, slot=None): 'failedInstancesLogs': arm_status.get('failedInstancesLogs') or [], } logger.warning('View deployment details in Kudu: %s', payload['kuduUrl']) - logger.warning('Kudu access is required to open this link.') return payload From 0dc619ecc088cc2a6bc1794bb6c87987f338c002 Mon Sep 17 00:00:00 2001 From: Shikha Jha Date: Mon, 28 Sep 2026 11:53:47 +0530 Subject: [PATCH 07/10] update help --- src/azure-cli/azure/cli/command_modules/appservice/_help.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_help.py b/src/azure-cli/azure/cli/command_modules/appservice/_help.py index b559de36f57..376dfde1e7f 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -3624,10 +3624,12 @@ helps['webapp secure-build show'] = """ type: command short-summary: Show the Secure Build report for the active deployment of a Linux web app. - long-summary: "The service uses a cached report when available. Use --rescan to request fresh dependency analysis, which can take several minutes. Default table output shows a human-readable summary and up to 20 findings. Standard --query processing remains available and controls the resulting table shape. Use JSON output or the provided Kudu link for the full report. Secure Build currently supports Python dependency information produced by supported platform builds." + long-summary: "The service uses a cached report when available. Use --rescan to request fresh dependency analysis, which can take several minutes. Output follows the Azure CLI output configuration and is JSON when no format is configured. Use --output table for a human-readable summary and up to 20 findings; when --query is supplied, standard query processing controls the resulting table shape. JSON output and the provided Kudu link contain the full report. Secure Build currently supports Python dependency information produced by supported platform builds." examples: - - name: Show the Secure Build report for the active deployment. + - name: Show a human-readable Secure Build report for the active deployment. text: az webapp secure-build show --resource-group ResourceGroup --name AppName --output table + - name: Show the complete Secure Build report as JSON. + text: az webapp secure-build show --resource-group ResourceGroup --name AppName --output json - name: Run a fresh analysis for a deployment slot and show critical findings. text: az webapp secure-build show --resource-group ResourceGroup --name AppName --slot staging --rescan --query "findings[?advisory.severity=='CRITICAL']" --output table """ From d3344bc1ee63a0bf02cf72a10e8b0e6d59e2bd74 Mon Sep 17 00:00:00 2001 From: Shikha Jha Date: Mon, 28 Sep 2026 13:46:56 +0530 Subject: [PATCH 08/10] add linux webapp gate for troubleshoot deployment --- .../cli/command_modules/appservice/_help.py | 4 ++-- .../cli/command_modules/appservice/custom.py | 3 +++ .../latest/test_webapp_commands_thru_mock.py | 22 +++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_help.py b/src/azure-cli/azure/cli/command_modules/appservice/_help.py index 376dfde1e7f..7240edab63e 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -3636,8 +3636,8 @@ helps['webapp troubleshoot deployment'] = """ type: command - short-summary: Show the latest deployment state and diagnostic information for a web app. - long-summary: Returns a point-in-time snapshot from Kudu and enriches it with platform build and runtime status when available. The command does not wait for deployment completion. + short-summary: Show the latest deployment state and diagnostic information for a Linux web app. + long-summary: Returns a point-in-time snapshot from Kudu and enriches it with platform build and runtime status when available. The command supports Linux web apps and does not wait for deployment completion. examples: - name: Show the latest deployment status for a web app. text: az webapp troubleshoot deployment --resource-group ResourceGroup --name AppName diff --git a/src/azure-cli/azure/cli/command_modules/appservice/custom.py b/src/azure-cli/azure/cli/command_modules/appservice/custom.py index 4aeff2c0572..490d9eebce6 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -7121,6 +7121,9 @@ def troubleshoot_deployment(cmd, resource_group_name, name, slot=None): import requests from azure.cli.core.util import should_disable_connection_verify + _ensure_linux_webapp(cmd, resource_group_name, name, slot, + command_label="'az webapp troubleshoot deployment'") + scm_url = _get_scm_url(cmd, resource_group_name, name, slot) headers = get_scm_site_headers(cmd.cli_ctx, name, resource_group_name, slot) latest_url = '{}/api/deployments/latest'.format(scm_url) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py index 29adaeddeed..0d7efec18f8 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py @@ -318,6 +318,28 @@ def test_secure_build_vulnerability_uses_terminal_hyperlink( class TestTroubleshootDeploymentMocked(unittest.TestCase): + def setUp(self): + ensure_linux_patcher = mock.patch( + 'azure.cli.command_modules.appservice.custom._ensure_linux_webapp') + self.ensure_linux_mock = ensure_linux_patcher.start() + self.addCleanup(ensure_linux_patcher.stop) + + @mock.patch('requests.get') + def test_troubleshoot_deployment_raises_on_windows(self, requests_get_mock): + self.ensure_linux_mock.side_effect = ArgumentUsageError( + "'az webapp troubleshoot deployment' is only supported for Linux web apps.") + + with self.assertRaises(ArgumentUsageError) as context: + troubleshoot_deployment(_get_test_cmd(), 'myRG', 'myWindowsApp') + + self.assertIn( + "'az webapp troubleshoot deployment' is only supported for Linux web apps.", + str(context.exception)) + self.ensure_linux_mock.assert_called_once_with( + mock.ANY, 'myRG', 'myWindowsApp', None, + command_label="'az webapp troubleshoot deployment'") + requests_get_mock.assert_not_called() + @mock.patch('azure.cli.core.util.should_disable_connection_verify', return_value=False) @mock.patch('azure.cli.command_modules.appservice.custom._get_arm_deployment_status') @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', From f6e6df16456cf190737ba59084f8f35174bc6b53 Mon Sep 17 00:00:00 2001 From: Shikha Jha Date: Mon, 28 Sep 2026 15:25:39 +0530 Subject: [PATCH 09/10] add secure-build command for async deployment --- .../cli/command_modules/appservice/_params.py | 5 +- .../cli/command_modules/appservice/custom.py | 15 +++--- .../latest/test_webapp_commands_thru_mock.py | 53 +++++++++++++++++++ 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_params.py b/src/azure-cli/azure/cli/command_modules/appservice/_params.py index 886d72ceb98..3f23dfa877f 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -1166,8 +1166,9 @@ def load_arguments(self, _): help='Linux only. A friendly name used to identify the deployment.') c.argument( 'show_secure_build', options_list=['--show-secure-build'], action='store_true', default=False, - help='Show a Secure Build summary and full-report link after deployment. ' - 'This option can add several minutes to the command.') + help='Show a Secure Build summary and full-report link after a synchronous deployment. For asynchronous ' + 'and URL deployments, show the command to retrieve the report after deployment completes. Supported ' + 'only for Linux web apps. Secure Build analysis can add several minutes to a synchronous deployment.') with self.argument_context('functionapp deploy') as c: c.argument('name', options_list=['--name', '-n'], help='Name of the function app to deploy to.') diff --git a/src/azure-cli/azure/cli/command_modules/appservice/custom.py b/src/azure-cli/azure/cli/command_modules/appservice/custom.py index 490d9eebce6..a579acd1770 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -7064,6 +7064,13 @@ def _show_secure_build_after_deployment(params): command = _secure_build_command( params.webapp_name, params.resource_group_name, params.slot) + if params.src_url or params.is_async_deployment is True: + logger.warning( + "The deployment was submitted asynchronously. After it completes, run '%s' to view Secure Build " + "analysis.", + command) + return + try: report = _request_secure_build_report( params.cmd, params.resource_group_name, params.webapp_name, params.slot) @@ -12930,13 +12937,7 @@ def _make_onedeploy_request(params): if not (poll_async_deployment_for_debugging and params.track_status): _log_webapp_troubleshoot_status_tip(params.webapp_name, params.resource_group_name, params.is_linux_webapp) if params.show_secure_build: - if poll_async_deployment_for_debugging: - _show_secure_build_after_deployment(params) - else: - logger.warning( - "Deployment was submitted asynchronously. Run '%s' after it completes to view Secure Build " - "analysis.", - _secure_build_command(params.webapp_name, params.resource_group_name, params.slot)) + _show_secure_build_after_deployment(params) logger.warning("You can visit your app at: %s", _get_visit_url(params)) return response_body diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py index 0d7efec18f8..a8a2181c88a 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py @@ -130,6 +130,8 @@ def test_post_deployment_summary_does_not_raise(self, request_mock, logger_mock) webapp_name='myApp', resource_group_name='myRG', slot=None, + src_url=None, + is_async_deployment=False, cmd=_get_test_cmd()) _show_secure_build_after_deployment(params) @@ -150,6 +152,8 @@ def test_post_deployment_shows_summary_and_link_for_same_report( webapp_name='myApp', resource_group_name='myRG', slot=None, + src_url=None, + is_async_deployment=False, cmd=_get_test_cmd()) _show_secure_build_after_deployment(params) @@ -158,6 +162,55 @@ def test_post_deployment_shows_summary_and_link_for_same_report( footer_mock.assert_called_once_with(report) self.assertIn('view findings from this report', logger_mock.warning.call_args.args[0]) + @mock.patch('azure.cli.command_modules.appservice.custom.logger') + @mock.patch('azure.cli.command_modules.appservice.custom._request_secure_build_report') + def test_post_deployment_defers_secure_build_for_async_deployments(self, request_mock, logger_mock): + cases = ( + (None, True, None, 'az webapp secure-build show --name myApp --resource-group myRG'), + ('https://example.com/app.zip', None, 'staging', + 'az webapp secure-build show --name myApp --resource-group myRG --slot staging'), + ('https://example.com/app.zip', False, None, + 'az webapp secure-build show --name myApp --resource-group myRG'), + ) + for src_url, is_async, slot, expected_command in cases: + with self.subTest(src_url=src_url, is_async=is_async, slot=slot): + logger_mock.reset_mock() + params = mock.MagicMock( + is_linux_webapp=True, + webapp_name='myApp', + resource_group_name='myRG', + slot=slot, + src_url=src_url, + is_async_deployment=is_async, + cmd=_get_test_cmd()) + + _show_secure_build_after_deployment(params) + + logger_mock.warning.assert_called_once_with( + "The deployment was submitted asynchronously. After it completes, run '%s' to view Secure " + "Build analysis.", + expected_command) + request_mock.assert_not_called() + + @mock.patch('azure.cli.command_modules.appservice.custom.logger') + @mock.patch('azure.cli.command_modules.appservice.custom._request_secure_build_report') + def test_post_deployment_secure_build_reports_linux_only_before_async_guidance( + self, request_mock, logger_mock): + params = mock.MagicMock( + is_linux_webapp=False, + webapp_name='myWindowsApp', + resource_group_name='myRG', + slot=None, + src_url='https://example.com/app.zip', + is_async_deployment=True, + cmd=_get_test_cmd()) + + _show_secure_build_after_deployment(params) + + logger_mock.warning.assert_called_once_with( + 'Secure Build analysis is currently supported only for Linux web apps.') + request_mock.assert_not_called() + @mock.patch('azure.cli.command_modules.appservice.custom._render_secure_build_table_report') def test_secure_build_table_output_renders_full_report(self, render_mock): report = { From c5be2582884be66e811a37c7ab1e7e4c3f97d5eb Mon Sep 17 00:00:00 2001 From: Shikha Jha Date: Mon, 28 Sep 2026 16:37:53 +0530 Subject: [PATCH 10/10] fix --- .../cli/command_modules/appservice/_params.py | 9 +- .../cli/command_modules/appservice/custom.py | 10 +- .../latest/test_webapp_commands_thru_mock.py | 111 +++++++++++++++++- 3 files changed, 116 insertions(+), 14 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_params.py b/src/azure-cli/azure/cli/command_modules/appservice/_params.py index 3f23dfa877f..52946dbb083 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -1164,11 +1164,10 @@ def load_arguments(self, _): 'deployment_tag', options_list=['--deploymentTag', c.deprecate(target='--tag', redirect='--deploymentTag')], help='Linux only. A friendly name used to identify the deployment.') - c.argument( - 'show_secure_build', options_list=['--show-secure-build'], action='store_true', default=False, - help='Show a Secure Build summary and full-report link after a synchronous deployment. For asynchronous ' - 'and URL deployments, show the command to retrieve the report after deployment completes. Supported ' - 'only for Linux web apps. Secure Build analysis can add several minutes to a synchronous deployment.') + c.argument('show_secure_build', options_list=['--show-secure-build'], action='store_true', default=False, + help='Show a Secure Build summary and full-report link after a local deployment, including when --async ' + 'true is used. For URL deployments, show the command to run after deployment completes. Supported ' + 'only for Linux web apps. Secure Build analysis can add several minutes to a local deployment.') with self.argument_context('functionapp deploy') as c: c.argument('name', options_list=['--name', '-n'], help='Name of the function app to deploy to.') diff --git a/src/azure-cli/azure/cli/command_modules/appservice/custom.py b/src/azure-cli/azure/cli/command_modules/appservice/custom.py index a579acd1770..218ed67f79f 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -7064,10 +7064,9 @@ def _show_secure_build_after_deployment(params): command = _secure_build_command( params.webapp_name, params.resource_group_name, params.slot) - if params.src_url or params.is_async_deployment is True: + if params.src_url: logger.warning( - "The deployment was submitted asynchronously. After it completes, run '%s' to view Secure Build " - "analysis.", + "After the deployment completes, run '%s' to view Secure Build analysis.", command) return @@ -12933,7 +12932,10 @@ def _make_onedeploy_request(params): if state: logger.warning("Deployment status is: \"%s\"", state) response_body = response.json().get("properties", {}) - logger.warning("Deployment has completed successfully") + if params.src_url: + logger.warning("Deployment was submitted asynchronously") + else: + logger.warning("Deployment has completed successfully") if not (poll_async_deployment_for_debugging and params.track_status): _log_webapp_troubleshoot_status_tip(params.webapp_name, params.resource_group_name, params.is_linux_webapp) if params.show_secure_build: diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py index a8a2181c88a..ec0e1b13668 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py @@ -164,9 +164,8 @@ def test_post_deployment_shows_summary_and_link_for_same_report( @mock.patch('azure.cli.command_modules.appservice.custom.logger') @mock.patch('azure.cli.command_modules.appservice.custom._request_secure_build_report') - def test_post_deployment_defers_secure_build_for_async_deployments(self, request_mock, logger_mock): + def test_post_deployment_defers_secure_build_for_url_deployments(self, request_mock, logger_mock): cases = ( - (None, True, None, 'az webapp secure-build show --name myApp --resource-group myRG'), ('https://example.com/app.zip', None, 'staging', 'az webapp secure-build show --name myApp --resource-group myRG --slot staging'), ('https://example.com/app.zip', False, None, @@ -187,14 +186,39 @@ def test_post_deployment_defers_secure_build_for_async_deployments(self, request _show_secure_build_after_deployment(params) logger_mock.warning.assert_called_once_with( - "The deployment was submitted asynchronously. After it completes, run '%s' to view Secure " - "Build analysis.", + "After the deployment completes, run '%s' to view Secure Build analysis.", expected_command) request_mock.assert_not_called() + @mock.patch('azure.cli.command_modules.appservice.custom._print_secure_build_kudu_footer') + @mock.patch('azure.cli.command_modules.appservice.custom._log_secure_build_report_summary') + @mock.patch('azure.cli.command_modules.appservice.custom.logger') + @mock.patch('azure.cli.command_modules.appservice.custom._request_secure_build_report') + def test_post_deployment_shows_secure_build_for_local_async_deployment( + self, request_mock, logger_mock, summary_mock, footer_mock): + report = {'summary': {'vulnerabilitiesFound': 1}, 'kuduUrl': 'https://scm/securebuild'} + request_mock.return_value = report + params = mock.MagicMock( + is_linux_webapp=True, + webapp_name='myApp', + resource_group_name='myRG', + slot=None, + src_url=None, + is_async_deployment=True, + cmd=_get_test_cmd()) + + _show_secure_build_after_deployment(params) + + request_mock.assert_called_once_with(params.cmd, 'myRG', 'myApp', None) + summary_mock.assert_called_once_with(report) + footer_mock.assert_called_once_with(report) + logger_mock.warning.assert_called_once_with( + "Run '%s' to view findings from this report in the CLI.", + 'az webapp secure-build show --name myApp --resource-group myRG') + @mock.patch('azure.cli.command_modules.appservice.custom.logger') @mock.patch('azure.cli.command_modules.appservice.custom._request_secure_build_report') - def test_post_deployment_secure_build_reports_linux_only_before_async_guidance( + def test_post_deployment_secure_build_reports_linux_only_before_url_guidance( self, request_mock, logger_mock): params = mock.MagicMock( is_linux_webapp=False, @@ -211,6 +235,83 @@ def test_post_deployment_secure_build_reports_linux_only_before_async_guidance( 'Secure Build analysis is currently supported only for Linux web apps.') request_mock.assert_not_called() + @mock.patch('azure.cli.command_modules.appservice.custom._get_visit_url', return_value='https://myApp') + @mock.patch('azure.cli.command_modules.appservice.custom._log_webapp_troubleshoot_status_tip') + @mock.patch('azure.cli.command_modules.appservice.custom._get_ondeploy_headers', return_value={}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_status_url', return_value='status-url') + @mock.patch('azure.cli.command_modules.appservice.custom._build_onedeploy_url', return_value='deploy-url') + @mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_request_body', + return_value=('request-body', None)) + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('azure.cli.command_modules.appservice.custom.logger') + def test_url_deployment_reports_submission_and_preserves_response( + self, logger_mock, send_request_mock, _request_body_mock, _deploy_url_mock, _status_url_mock, + _headers_mock, _troubleshoot_tip_mock, _visit_url_mock): + from azure.cli.command_modules.appservice.custom import _make_onedeploy_request + + response_body = {'complete': False, 'status': 0, 'status_text': 'Receiving changes.'} + response = mock.MagicMock(status_code=202, headers={'content-type': 'application/json'}) + response.json.return_value = {'properties': response_body} + send_request_mock.return_value = response + params = mock.MagicMock( + src_url='https://example.com/app.zip', + is_linux_webapp=True, + is_functionapp=False, + enable_kudu_warmup=False, + track_status=True, + show_secure_build=False, + webapp_name='myApp', + resource_group_name='myRG', + slot=None, + cmd=_get_test_cmd()) + + result = _make_onedeploy_request(params) + + self.assertEqual(result, response_body) + logger_mock.warning.assert_any_call('Deployment was submitted asynchronously') + self.assertNotIn( + mock.call('Deployment has completed successfully'), + logger_mock.warning.call_args_list) + + @mock.patch('azure.cli.command_modules.appservice.custom._get_visit_url', return_value='https://myApp') + @mock.patch('azure.cli.command_modules.appservice.custom._check_runtimestatus_with_deploymentstatusapi') + @mock.patch('azure.cli.command_modules.appservice.custom._get_ondeploy_headers', return_value={}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_status_url', return_value='status-url') + @mock.patch('azure.cli.command_modules.appservice.custom._build_onedeploy_url', return_value='deploy-url') + @mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_request_body', + return_value=('request-body', None)) + @mock.patch('requests.post') + @mock.patch('azure.cli.command_modules.appservice.custom.logger') + def test_local_async_deployment_reports_completion_and_preserves_response( + self, logger_mock, post_mock, _request_body_mock, _deploy_url_mock, _status_url_mock, + _headers_mock, deployment_status_mock, _visit_url_mock): + from azure.cli.command_modules.appservice.custom import _make_onedeploy_request + + response_body = {'complete': False, 'status': 0, 'status_text': 'Receiving changes.'} + post_mock.return_value = mock.MagicMock(status_code=202) + deployment_status_mock.return_value = response_body + params = mock.MagicMock( + src_url=None, + is_async_deployment=True, + is_linux_webapp=True, + is_functionapp=False, + enable_kudu_warmup=False, + track_status=True, + show_secure_build=False, + webapp_name='myApp', + resource_group_name='myRG', + slot=None, + timeout=None, + cmd=_get_test_cmd()) + + result = _make_onedeploy_request(params) + + self.assertEqual(result, response_body) + logger_mock.warning.assert_any_call('Deployment has completed successfully') + self.assertNotIn( + mock.call('Deployment was submitted asynchronously'), + logger_mock.warning.call_args_list) + @mock.patch('azure.cli.command_modules.appservice.custom._render_secure_build_table_report') def test_secure_build_table_output_renders_full_report(self, render_mock): report = {