diff --git a/cycode/cli/app.py b/cycode/cli/app.py index 41391f99..0e9f9c7b 100644 --- a/cycode/cli/app.py +++ b/cycode/cli/app.py @@ -166,6 +166,8 @@ def app_callback( if user_agent: user_agent_option = UserAgentOptionScheme().loads(user_agent) CycodeClientBase.enrich_user_agent(user_agent_option.user_agent_suffix) + ctx.obj['plugin_app_name'] = user_agent_option.app_name + ctx.obj['plugin_app_version'] = user_agent_option.app_version if not no_update_notifier: ctx.call_on_close(lambda: check_latest_version_on_close(ctx)) diff --git a/cycode/cli/apps/activation_manager.py b/cycode/cli/apps/activation_manager.py new file mode 100644 index 00000000..8eed3caa --- /dev/null +++ b/cycode/cli/apps/activation_manager.py @@ -0,0 +1,46 @@ +from typing import TYPE_CHECKING, Optional + +from cycode import __version__ +from cycode.cli.config import configuration_manager +from cycode.cyclient.cli_activation_client import CliActivationClient +from cycode.logger import get_logger + +if TYPE_CHECKING: + from cycode.cyclient.cycode_client_base import CycodeClientBase + +logger = get_logger('Activation Manager') + +_CLI_CLIENT_NAME = 'cli' + + +def _get_client_and_version(plugin_app_name: Optional[str], plugin_app_version: Optional[str]) -> tuple[str, str]: + return plugin_app_name or _CLI_CLIENT_NAME, plugin_app_version or __version__ + + +def should_report_cli_activation( + plugin_app_name: Optional[str] = None, + plugin_app_version: Optional[str] = None, +) -> bool: + client, version = _get_client_and_version(plugin_app_name, plugin_app_version) + return configuration_manager.get_last_reported_activation_version(client) != version + + +def report_cli_activation( + cycode_client: 'CycodeClientBase', + plugin_app_name: Optional[str] = None, + plugin_app_version: Optional[str] = None, +) -> None: + """Report CLI/IDE activation to the backend if the (client, version) pair is new. + + Failures are swallowed — activation tracking is non-critical. + """ + try: + client, version = _get_client_and_version(plugin_app_name, plugin_app_version) + + if configuration_manager.get_last_reported_activation_version(client) == version: + return + + CliActivationClient(cycode_client).report_activation() + configuration_manager.update_last_reported_activation_version(client, version) + except Exception: + logger.debug('Failed to report CLI activation', exc_info=True) diff --git a/cycode/cli/apps/auth/auth_command.py b/cycode/cli/apps/auth/auth_command.py index 1184a916..005e8c3e 100644 --- a/cycode/cli/apps/auth/auth_command.py +++ b/cycode/cli/apps/auth/auth_command.py @@ -1,9 +1,11 @@ import typer +from cycode.cli.apps.activation_manager import report_cli_activation, should_report_cli_activation from cycode.cli.apps.auth.auth_manager import AuthManager from cycode.cli.exceptions.handle_auth_errors import handle_auth_exception from cycode.cli.logger import logger from cycode.cli.models import CliResult +from cycode.cli.utils.get_api_client import get_scan_cycode_client def auth_command(ctx: typer.Context) -> None: @@ -23,6 +25,12 @@ def auth_command(ctx: typer.Context) -> None: auth_manager = AuthManager() auth_manager.authenticate() + plugin_app_name = ctx.obj.get('plugin_app_name') + plugin_app_version = ctx.obj.get('plugin_app_version') + if should_report_cli_activation(plugin_app_name, plugin_app_version): + scan_client = get_scan_cycode_client(ctx) + report_cli_activation(scan_client.scan_cycode_client, plugin_app_name, plugin_app_version) + result = CliResult(success=True, message='Successfully logged into cycode') printer.print_result(result) except Exception as err: diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 7aab9d27..56dd2a56 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -5,6 +5,7 @@ import click import typer +from cycode.cli.apps.activation_manager import report_cli_activation, should_report_cli_activation from cycode.cli.apps.sca_options import ( GradleAllSubProjectsOption, MavenSettingsFileOption, @@ -140,6 +141,11 @@ def scan_command( scan_client = get_scan_cycode_client(ctx) ctx.obj['client'] = scan_client + plugin_app_name = ctx.obj.get('plugin_app_name') + plugin_app_version = ctx.obj.get('plugin_app_version') + if should_report_cli_activation(plugin_app_name, plugin_app_version): + report_cli_activation(scan_client.scan_cycode_client, plugin_app_name, plugin_app_version) + # Get remote URL from current working directory remote_url = _try_get_git_remote_url(os.getcwd()) diff --git a/cycode/cli/apps/status/get_cli_status.py b/cycode/cli/apps/status/get_cli_status.py index 0cf6e8fd..7018fa29 100644 --- a/cycode/cli/apps/status/get_cli_status.py +++ b/cycode/cli/apps/status/get_cli_status.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING from cycode import __version__ +from cycode.cli.apps.activation_manager import report_cli_activation, should_report_cli_activation from cycode.cli.apps.auth.auth_common import get_authorization_info from cycode.cli.apps.status.models import CliStatus, CliSupportedModulesStatus from cycode.cli.consts import PROGRAM_NAME @@ -22,7 +23,11 @@ def get_cli_status(ctx: 'Context') -> CliStatus: supported_modules_status = CliSupportedModulesStatus() if is_authenticated: try: + plugin_app_name = ctx.obj.get('plugin_app_name') + plugin_app_version = ctx.obj.get('plugin_app_version') client = get_scan_cycode_client(ctx) + if should_report_cli_activation(plugin_app_name, plugin_app_version): + report_cli_activation(client.scan_cycode_client, plugin_app_name, plugin_app_version) supported_modules_preferences = client.get_supported_modules_preferences() supported_modules_status.secret_scanning = supported_modules_preferences.secret_scanning diff --git a/cycode/cli/user_settings/config_file_manager.py b/cycode/cli/user_settings/config_file_manager.py index 5b029e39..cfab38d2 100644 --- a/cycode/cli/user_settings/config_file_manager.py +++ b/cycode/cli/user_settings/config_file_manager.py @@ -18,6 +18,7 @@ class ConfigFileManager(BaseFileManager): SCAN_SECTION_NAME: str = 'scan' INSTALLATION_ID_FIELD_NAME: str = 'installation_id' + LAST_REPORTED_ACTIVATION_VERSIONS_FIELD_NAME: str = 'last_reported_activation_versions' API_URL_FIELD_NAME: str = 'cycode_api_url' APP_URL_FIELD_NAME: str = 'cycode_app_url' VERBOSE_FIELD_NAME: str = 'verbose' @@ -68,6 +69,16 @@ def update_installation_id(self, installation_id: str) -> None: update_data = {self.ENVIRONMENT_SECTION_NAME: {self.INSTALLATION_ID_FIELD_NAME: installation_id}} self.write_content_to_file(update_data) + def get_last_reported_activation_versions(self) -> dict[str, str]: + value = self._get_value_from_environment_section(self.LAST_REPORTED_ACTIVATION_VERSIONS_FIELD_NAME) + return value if isinstance(value, dict) else {} + + def update_last_reported_activation_version(self, client: str, version: str) -> None: + versions = self.get_last_reported_activation_versions() + versions[client] = version + update_data = {self.ENVIRONMENT_SECTION_NAME: {self.LAST_REPORTED_ACTIVATION_VERSIONS_FIELD_NAME: versions}} + self.write_content_to_file(update_data) + def add_exclusion(self, scan_type: str, exclusion_type: str, new_exclusion: str) -> None: exclusions = self._get_exclusions_by_exclusion_type(scan_type, exclusion_type) if new_exclusion in exclusions: diff --git a/cycode/cli/user_settings/configuration_manager.py b/cycode/cli/user_settings/configuration_manager.py index 689ec0d5..f80f9a6e 100644 --- a/cycode/cli/user_settings/configuration_manager.py +++ b/cycode/cli/user_settings/configuration_manager.py @@ -94,6 +94,12 @@ def get_or_create_installation_id(self) -> str: return installation_id + def get_last_reported_activation_version(self, client: str) -> Optional[str]: + return self.global_config_file_manager.get_last_reported_activation_versions().get(client) + + def update_last_reported_activation_version(self, client: str, version: str) -> None: + self.global_config_file_manager.update_last_reported_activation_version(client, version) + def get_config_file_manager(self, scope: Optional[str] = None) -> ConfigFileManager: if scope == 'local': return self.local_config_file_manager diff --git a/cycode/cyclient/cli_activation_client.py b/cycode/cyclient/cli_activation_client.py new file mode 100644 index 00000000..2932c353 --- /dev/null +++ b/cycode/cyclient/cli_activation_client.py @@ -0,0 +1,14 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from cycode.cyclient.cycode_client_base import CycodeClientBase + +_CLI_ACTIVATION_PATH = 'scans/api/v4/cli-activation' + + +class CliActivationClient: + def __init__(self, cycode_client: 'CycodeClientBase') -> None: + self._cycode_client = cycode_client + + def report_activation(self) -> None: + self._cycode_client.put(url_path=_CLI_ACTIVATION_PATH) diff --git a/tests/user_settings/test_activation_tracking.py b/tests/user_settings/test_activation_tracking.py new file mode 100644 index 00000000..5c8faf02 --- /dev/null +++ b/tests/user_settings/test_activation_tracking.py @@ -0,0 +1,57 @@ +from pathlib import Path + +import pytest + +from cycode.cli.user_settings.config_file_manager import ConfigFileManager + + +@pytest.fixture +def config_manager(tmp_path: Path) -> ConfigFileManager: + return ConfigFileManager(tmp_path) + + +def test_get_last_reported_activation_versions_returns_empty_when_not_set( + config_manager: ConfigFileManager, +) -> None: + assert config_manager.get_last_reported_activation_versions() == {} + + +def test_update_and_get_last_reported_activation_version_cli(config_manager: ConfigFileManager) -> None: + config_manager.update_last_reported_activation_version('cli', '1.10.7') + + assert config_manager.get_last_reported_activation_versions() == {'cli': '1.10.7'} + + +def test_update_and_get_last_reported_activation_version_plugin(config_manager: ConfigFileManager) -> None: + config_manager.update_last_reported_activation_version('vscode_extension', '2.0.0') + + assert config_manager.get_last_reported_activation_versions() == {'vscode_extension': '2.0.0'} + + +def test_update_last_reported_activation_version_multiple_clients(config_manager: ConfigFileManager) -> None: + config_manager.update_last_reported_activation_version('cli', '1.10.7') + config_manager.update_last_reported_activation_version('vscode_extension', '2.0.0') + config_manager.update_last_reported_activation_version('jetbrains_extension', '1.5.0') + + assert config_manager.get_last_reported_activation_versions() == { + 'cli': '1.10.7', + 'vscode_extension': '2.0.0', + 'jetbrains_extension': '1.5.0', + } + + +def test_update_last_reported_activation_version_overwrites_existing(config_manager: ConfigFileManager) -> None: + config_manager.update_last_reported_activation_version('cli', '1.10.7') + config_manager.update_last_reported_activation_version('cli', '1.10.8') + + assert config_manager.get_last_reported_activation_versions() == {'cli': '1.10.8'} + + +def test_update_last_reported_activation_version_does_not_affect_other_clients( + config_manager: ConfigFileManager, +) -> None: + config_manager.update_last_reported_activation_version('cli', '1.10.7') + config_manager.update_last_reported_activation_version('vscode_extension', '2.0.0') + config_manager.update_last_reported_activation_version('cli', '1.10.8') + + assert config_manager.get_last_reported_activation_versions()['vscode_extension'] == '2.0.0'