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..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,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 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 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. 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 --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']" --output table
+"""
+
+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..eebe4caf32e 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,9 @@ 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 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 d29af8b1d59..3f14d6735b0 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,34 @@ def transform_runtime_list_output(result):
]) for r in result]
+def transform_secure_build_output(result):
+ 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)
+ return rows
+
+
+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'),
+ ('Active', result.get('active', False)),
+ ('Succeeded', runtime.get('instancesSuccessful', '-')),
+ ('Failed', runtime.get('instancesFailed', '-')),
+ ('LastDeploymentTime', result.get('lastDeploymentTime') or '-'),
+ ])]
+
+
def transform_troubleshoot_config_output(result):
"""Flatten the troubleshoot config payload into a per-setting table.
@@ -380,6 +408,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..9133e0661fe 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, 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
@@ -51,7 +53,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 +6877,356 @@ 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.')
+ report['kuduUrl'] = '{}/securebuild'.format(scm_url)
+ 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 _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)]
+ 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')
+
+ scan_context = OrderedDict([
+ ('Deployment ID', report.get('deploymentId') or 'Unknown'),
+ ('Runtime', runtime_name),
+ ('Packages scanned', summary.get('packagesAssessed', 'Unknown')),
+ ])
+ if report.get('generatedAtUtc'):
+ scan_context['Report generated'] = report['generatedAtUtc']
+ scan_summary = tabulate(
+ [scan_context], headers='keys', tablefmt='simple', disable_numparse=True)
+
+ 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: Open source vulnerabilities in app packages\n'),
+ (Style.PRIMARY, 'Secure Build found '),
+ (Style.ERROR, vulnerability_label),
+ (Style.PRIMARY, ' in {}.\n\n{}'.format(affected_package_label, scan_summary)),
+ ], file=output_file)
+ else:
+ print_styled_text([
+ (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{}'.format(scan_summary)),
+ ], file=output_file)
+
+
+def _secure_build_finding_rows(report):
+ from collections import OrderedDict
+
+ rows = []
+ if isinstance(report, dict):
+ findings = report.get('findings') or []
+ elif isinstance(report, list):
+ findings = report
+ else:
+ return rows
+ for finding in findings:
+ 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
+ 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, 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, '\nTo view the full report, visit: '),
+ (Style.HYPERLINK, report['kuduUrl']),
+ ], file=output_file)
+
+
+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([
+ 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=output_file)
+ _print_secure_build_kudu_footer(report, file=output_file)
+
+
+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
+
+ 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)
+
+
+_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:
+ result = {
+ 'name': name,
+ 'resourceGroup': resource_group_name,
+ 'slot': slot,
+ 'state': 'NoDeployment',
+ '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'])
+ return result
+ 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'))
+ kudu_url = '{}/api/deployments'.format(scm_url)
+ if deployment_id:
+ kudu_url += '/{}'.format(quote(str(deployment_id), safe=''))
+
+ payload = {
+ 'name': name,
+ 'resourceGroup': resource_group_name,
+ 'slot': slot,
+ 'deploymentId': deployment_id,
+ 'activeDeploymentId': deployment_id if active else None,
+ 'kuduUrl': kudu_url,
+ '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 [],
+ }
+ logger.warning('View deployment details in Kudu: %s', payload['kuduUrl'])
+ 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 +12398,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 +12419,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 +12467,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 +12926,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..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,
@@ -45,8 +47,16 @@
_extract_runtime_error,
_log_webapp_troubleshoot_config_tip,
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
+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 +75,372 @@ 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['summary'], report['summary'])
+ self.assertEqual(result['findings'], report['findings'])
+ self.assertEqual(
+ result['kuduUrl'],
+ 'https://myapp.scm.azurewebsites.net/securebuild')
+ 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])
+
+ @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'}}]
+ }
+
+ 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 = [
+ {'package': 'sample-{}'.format(index), 'version': '1.0', 'advisory': {}}
+ for index in range(25)
+ ]
+
+ 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([]), [])
+
+ 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):
+ _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: 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)
+ 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)
+ 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):
+ _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)
+ success_parts = [part for part in summary_parts if part[0].value == 'success']
+ self.assertEqual(len(success_parts), 1)
+ self.assertEqual(
+ success_parts[0][1],
+ 'Secure Build detected no critical vulnerabilities. '
+ 'The scanned packages have no matching alerts.')
+ 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):
+ _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)\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.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('CVE-2026-0001', link)
+ self.assertNotIn('\x1b[4m', link)
+ self.assertTrue(link.endswith('\x1b]8;;\x1b\\'))
+
+
+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)
+ 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'},
+ 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)
+ 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={})
+ @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')
+ self.assertEqual(result['kuduUrl'], 'https://scm/api/deployments')
+
+ 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.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):
@mock.patch('azure.cli.command_modules.appservice.custom.logger')
@@ -1659,6 +2035,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