Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 8 additions & 21 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"configurations": [
{
"name": "Azure CLI Debug (Integrated Console)",
"type": "python",
"type": "debugpy",
"request": "launch",
"python": "${command:python.interpreterPath}",
"program": "${workspaceRoot}/src/azure-cli/azure/cli/__main__.py",
Expand All @@ -12,46 +12,33 @@
"--help"
],
"console": "integratedTerminal",
"debugOptions": [
"WaitOnAbnormalExit",
"WaitOnNormalExit",
"RedirectOutput"
],
"justMyCode": false
},
{
"name": "Azure CLI Debug (External Console)",
"type": "python",
"type": "debugpy",
"request": "launch",
"stopOnEntry": true,
"python": "${command:python.interpreterPath}",
"program": "${workspaceRoot}/src/azure-cli/azure/cli/__main__.py",
"cwd": "${workspaceRoot}",
"args": [
"--help"
"vm",
"show"
],
"console": "externalTerminal",
"debugOptions": [
"WaitOnAbnormalExit",
"WaitOnNormalExit"
]
"console": "externalTerminal"
},
{
"name": "Azdev Scripts",
"type": "python",
"type": "debugpy",
"request": "launch",
"python": "${command:python.interpreterPath}",
"program": "${workspaceRoot}/tools/automation/__main__.py",
"cwd": "${workspaceRoot}",
"args": [
"--help"
],
"console": "integratedTerminal",
"debugOptions": [
"WaitOnAbnormalExit",
"WaitOnNormalExit",
"RedirectOutput"
]
"console": "integratedTerminal"
}
]
}
}
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 @@ -239,6 +239,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', 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