Skip to content
2 changes: 2 additions & 0 deletions src/azure-cli-core/azure/cli/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,8 @@ def _update_command_table_from_modules(args, command_modules=None):
command_modules.extend(ALWAYS_LOADED_MODULES)
else:
# Perform module discovery
from azure.cli.core import telemetry
telemetry.set_command_index_rebuild_triggered(True)
command_modules = []
try:
mods_ns_pkg = import_module('azure.cli.command_modules')
Expand Down
7 changes: 5 additions & 2 deletions src/azure-cli-core/azure/cli/core/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,8 +512,10 @@ def execute(self, args):
EVENT_INVOKER_FILTER_RESULT)
from azure.cli.core.commands.events import (
EVENT_INVOKER_PRE_CMD_TBL_TRUNCATE, EVENT_INVOKER_PRE_LOAD_ARGUMENTS, EVENT_INVOKER_POST_LOAD_ARGUMENTS)
from azure.cli.core.util import roughly_parse_command_with_casing

# TODO: Can't simply be invoked as an event because args are transformed
command_preserve_casing = roughly_parse_command_with_casing(args)
args = _pre_command_table_create(self.cli_ctx, args)

self.cli_ctx.raise_event(EVENT_INVOKER_PRE_CMD_TBL_CREATE, args=args)
Expand Down Expand Up @@ -578,7 +580,7 @@ def execute(self, args):
self.help.show_welcome(subparser)

# TODO: No event in base with which to target
telemetry.set_command_details('az')
telemetry.set_command_details('az', command_preserve_casing=command_preserve_casing)
telemetry.set_success(summary='welcome')
return CommandResultItem(None, exit_code=0)

Expand Down Expand Up @@ -633,7 +635,8 @@ def execute(self, args):
pass
telemetry.set_command_details(self.cli_ctx.data['command'], self.data['output'],
self.cli_ctx.data['safe_params'],
extension_name=extension_name, extension_version=extension_version)
extension_name=extension_name, extension_version=extension_version,
command_preserve_casing=command_preserve_casing)
if extension_name:
self.data['command_extension_name'] = extension_name
self.cli_ctx.logging.log_cmd_metadata_extension_info(extension_name, extension_version)
Expand Down
14 changes: 13 additions & 1 deletion src/azure-cli-core/azure/cli/core/telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ def __init__(self, correlation_id=None, application=None):
self.feedback = None
self.extension_management_detail = None
self.raw_command = None
self.command_preserve_casing = None
self.cmd_idx_rebuild_triggered = False
self.show_survey_message = False
self.region_input = None
self.region_identified = None
Expand Down Expand Up @@ -207,6 +209,9 @@ def _get_azure_cli_properties(self):
set_custom_properties(result, 'InvokeTimeElapsed', str(self.invoke_time_elapsed))
set_custom_properties(result, 'OutputType', self.output_type)
set_custom_properties(result, 'RawCommand', self.raw_command)
set_custom_properties(result, 'CommandPreserveCasing',
self.command_preserve_casing or '')
set_custom_properties(result, 'CmdIdxRebuildTriggered', str(self.cmd_idx_rebuild_triggered))
set_custom_properties(result, 'Params', ','.join(self.parameters or []))
set_custom_properties(result, 'PythonVersion', platform.python_version())
set_custom_properties(result, 'ModuleCorrelation', self.module_correlation)
Expand Down Expand Up @@ -437,12 +442,19 @@ def set_extension_management_detail(ext_name, ext_version):


@decorators.suppress_all_exceptions()
def set_command_details(command, output_type=None, parameters=None, extension_name=None, extension_version=None):
def set_command_index_rebuild_triggered(cmd_idx_rebuild_triggered=False):
_session.cmd_idx_rebuild_triggered = cmd_idx_rebuild_triggered


@decorators.suppress_all_exceptions()
def set_command_details(command, output_type=None, parameters=None, extension_name=None,
extension_version=None, command_preserve_casing=None):
_session.command = command
_session.output_type = output_type
_session.parameters = parameters
_session.extension_name = extension_name
_session.extension_version = extension_version
_session.command_preserve_casing = command_preserve_casing


@decorators.suppress_all_exceptions()
Expand Down
79 changes: 78 additions & 1 deletion src/azure-cli-core/azure/cli/core/tests/test_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
(get_file_json, truncate_text, shell_safe_json_parse, b64_to_hex, hash_string, random_string,
open_page_in_browser, can_launch_browser, handle_exception, ConfiguredDefaultSetter, send_raw_request,
should_disable_connection_verify, parse_proxy_resource_id, get_az_user_agent, get_az_rest_user_agent,
_get_parent_proc_name, is_wsl, run_cmd, run_az_cmd)
_get_parent_proc_name, is_wsl, run_cmd, run_az_cmd, roughly_parse_command, roughly_parse_command_with_casing)
from azure.cli.core.mock import DummyCli


Expand Down Expand Up @@ -612,6 +612,83 @@ def _get_mock_HttpOperationError(response_text):

return mock_http_error

def test_roughly_parse_command(self):
"""Test roughly_parse_command function that extracts command parts and converts to lowercase"""
# Basic command parsing
self.assertEqual(roughly_parse_command(['az', 'vm', 'create']), 'az vm create')
self.assertEqual(roughly_parse_command(['account', 'show']), 'account show')
self.assertEqual(roughly_parse_command(['network', 'vnet', 'list']), 'network vnet list')

# Test case conversion - should convert to lowercase
self.assertEqual(roughly_parse_command(['az', 'VM', 'CREATE']), 'az vm create')
self.assertEqual(roughly_parse_command(['Account', 'Show']), 'account show')

# Test with flags - should stop at first flag and not include flag values
self.assertEqual(roughly_parse_command(['az', 'vm', 'create', '--name', 'secretVM']), 'az vm create')
self.assertEqual(roughly_parse_command(['az', 'storage', 'account', 'create', '--name', 'mystorageaccount']), 'az storage account create')
self.assertEqual(roughly_parse_command(['az', 'keyvault', 'create', '--resource-group', 'myRG', '--name', 'myVault']), 'az keyvault create')

# Test with short flags
self.assertEqual(roughly_parse_command(['az', 'vm', 'list', '-g', 'myResourceGroup']), 'az vm list')
self.assertEqual(roughly_parse_command(['az', 'group', 'create', '-n', 'myGroup', '-l', 'eastus']), 'az group create')

# Edge cases
self.assertEqual(roughly_parse_command([]), '')
self.assertEqual(roughly_parse_command(['az']), 'az')
self.assertEqual(roughly_parse_command(['--help']), '') # Starts with flag
self.assertEqual(roughly_parse_command(['-h']), '') # Starts with short flag

def test_roughly_parse_command_with_casing(self):
"""Test roughly_parse_command_with_casing function that preserves original casing"""
# Basic command parsing with case preservation
self.assertEqual(roughly_parse_command_with_casing(['az', 'vm', 'create']), 'az vm create')
self.assertEqual(roughly_parse_command_with_casing(['account', 'show']), 'account show')
self.assertEqual(roughly_parse_command_with_casing(['network', 'vnet', 'list']), 'network vnet list')

# Test case preservation - should keep original casing
self.assertEqual(roughly_parse_command_with_casing(['az', 'VM', 'CREATE']), 'az VM CREATE')
self.assertEqual(roughly_parse_command_with_casing(['Account', 'Show']), 'Account Show')
self.assertEqual(roughly_parse_command_with_casing(['Az', 'Network', 'Vnet', 'List']), 'Az Network Vnet List')

# Test with flags - should stop at first flag and not include sensitive flag values
self.assertEqual(roughly_parse_command_with_casing(['az', 'vm', 'create', '--name', 'secretVM']), 'az vm create')
self.assertEqual(roughly_parse_command_with_casing(['az', 'VM', 'Create', '--name', 'superSecretVM']), 'az VM Create')
self.assertEqual(roughly_parse_command_with_casing(['az', 'storage', 'account', 'create', '--name', 'mystorageaccount']), 'az storage account create')
self.assertEqual(roughly_parse_command_with_casing(['az', 'keyvault', 'create', '--resource-group', 'myRG', '--name', 'myVault']), 'az keyvault create')

# Test with short flags
self.assertEqual(roughly_parse_command_with_casing(['az', 'VM', 'list', '-g', 'myResourceGroup']), 'az VM list')
self.assertEqual(roughly_parse_command_with_casing(['az', 'Group', 'create', '-n', 'myGroup', '-l', 'eastus']), 'az Group create')

# Test mixed case scenarios that might reveal user typing patterns
self.assertEqual(roughly_parse_command_with_casing(['Az', 'Vm', 'Create']), 'Az Vm Create')
self.assertEqual(roughly_parse_command_with_casing(['AZ', 'STORAGE', 'BLOB', 'LIST']), 'AZ STORAGE BLOB LIST')

# Edge cases
self.assertEqual(roughly_parse_command_with_casing([]), '')
self.assertEqual(roughly_parse_command_with_casing(['az']), 'az')
self.assertEqual(roughly_parse_command_with_casing(['Az']), 'Az')
self.assertEqual(roughly_parse_command_with_casing(['--help']), '') # Starts with flag
self.assertEqual(roughly_parse_command_with_casing(['-h']), '') # Starts with short flag

# Security test - ensure no sensitive information leaks after flags
test_cases_with_secrets = [
(['az', 'vm', 'create', '--admin-password', 'SuperSecret123!'], 'az vm create'),
(['az', 'sql', 'server', 'create', '--admin-user', 'admin', '--admin-password', 'VerySecret!'], 'az sql server create'),
(['az', 'storage', 'account', 'create', '--name', 'storageacct', '--access-tier', 'Hot'], 'az storage account create'),
(['Az', 'KeyVault', 'Secret', 'Set', '--vault-name', 'myVault', '--name', 'secretName', '--value', 'topSecret'], 'Az KeyVault Secret Set')
]

for args, expected in test_cases_with_secrets:
with self.subTest(args=args):
result = roughly_parse_command_with_casing(args)
self.assertEqual(result, expected)
# Ensure no sensitive values made it through
self.assertNotIn('SuperSecret123!', result)
self.assertNotIn('VerySecret!', result)
self.assertNotIn('topSecret', result)
self.assertNotIn('storageacct', result) # Even non-secret values after flags should not appear


if __name__ == '__main__':
unittest.main()
13 changes: 13 additions & 0 deletions src/azure-cli-core/azure/cli/core/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -1281,6 +1281,19 @@ def roughly_parse_command(args):
return ' '.join(nouns).lower()


def roughly_parse_command_with_casing(args):
# Roughly parse the command part: <az VM create> --name vm1
# Similar to knack.invocation.CommandInvoker._rudimentary_get_command, but preserves original casing
# and we don't need to bother with positional args
nouns = []
for arg in args:
if arg and arg[0] != '-':
nouns.append(arg)
else:
break
return ' '.join(nouns)


def is_guid(guid):
import uuid
try:
Expand Down