From 8b3e4d5171f9c009e7bbe734d239017bc072ed2b Mon Sep 17 00:00:00 2001 From: Shikha Jha Date: Thu, 24 Sep 2026 18:42:01 +0530 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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