diff --git a/cycode/cli/commands/auth/auth_command.py b/cycode/cli/commands/auth/auth_command.py index 30dfab42..d7787ad5 100644 --- a/cycode/cli/commands/auth/auth_command.py +++ b/cycode/cli/commands/auth/auth_command.py @@ -4,7 +4,9 @@ from cycode.cli.exceptions.custom_exceptions import AuthProcessError, HttpUnauthorizedError, NetworkError from cycode.cli.models import CliError, CliErrors, CliResult from cycode.cli.printers import ConsolePrinter +from cycode.cli.sentry import add_breadcrumb, capture_exception from cycode.cli.user_settings.credentials_manager import CredentialsManager +from cycode.cli.utils.jwt_utils import get_user_and_tenant_ids_from_access_token from cycode.cyclient import logger from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient @@ -15,6 +17,8 @@ @click.pass_context def auth_command(context: click.Context) -> None: """Authenticates your machine.""" + add_breadcrumb('auth') + if context.invoked_subcommand is not None: # if it is a subcommand, do nothing return @@ -37,9 +41,10 @@ def auth_command(context: click.Context) -> None: @click.pass_context def authorization_check(context: click.Context) -> None: """Validates that your Cycode account has permission to work with the CLI.""" + add_breadcrumb('check') + printer = ConsolePrinter(context) - passed_auth_check_res = CliResult(success=True, message='Cycode authentication verified') failed_auth_check_res = CliResult(success=False, message='Cycode authentication failed') client_id, client_secret = CredentialsManager().get_credentials() @@ -48,9 +53,21 @@ def authorization_check(context: click.Context) -> None: return try: - if CycodeTokenBasedClient(client_id, client_secret).get_access_token(): - printer.print_result(passed_auth_check_res) + access_token = CycodeTokenBasedClient(client_id, client_secret).get_access_token() + if not access_token: + printer.print_result(failed_auth_check_res) return + + user_id, tenant_id = get_user_and_tenant_ids_from_access_token(access_token) + printer.print_result( + CliResult( + success=True, + message='Cycode authentication verified', + data={'user_id': user_id, 'tenant_id': tenant_id}, + ) + ) + + return except (NetworkError, HttpUnauthorizedError): ConsolePrinter(context).print_exception() @@ -78,4 +95,6 @@ def _handle_exception(context: click.Context, e: Exception) -> None: if isinstance(e, click.ClickException): raise e + capture_exception(e) + raise click.ClickException(str(e)) diff --git a/cycode/cli/commands/configure/configure_command.py b/cycode/cli/commands/configure/configure_command.py index 5fe695ac..8f76d159 100644 --- a/cycode/cli/commands/configure/configure_command.py +++ b/cycode/cli/commands/configure/configure_command.py @@ -3,6 +3,7 @@ import click from cycode.cli import config, consts +from cycode.cli.sentry import add_breadcrumb from cycode.cli.user_settings.configuration_manager import ConfigurationManager from cycode.cli.user_settings.credentials_manager import CredentialsManager from cycode.cli.utils.string_utils import obfuscate_text @@ -26,6 +27,8 @@ @click.command(short_help='Initial command to configure your CLI client authentication.') def configure_command() -> None: """Configure your CLI client authentication manually.""" + add_breadcrumb('configure') + global_config_manager = _CONFIGURATION_MANAGER.global_config_file_manager current_api_url = global_config_manager.get_api_url() diff --git a/cycode/cli/commands/ignore/ignore_command.py b/cycode/cli/commands/ignore/ignore_command.py index 66515447..ea73a8e6 100644 --- a/cycode/cli/commands/ignore/ignore_command.py +++ b/cycode/cli/commands/ignore/ignore_command.py @@ -5,6 +5,7 @@ from cycode.cli import consts from cycode.cli.config import config, configuration_manager +from cycode.cli.sentry import add_breadcrumb from cycode.cli.utils.path_utils import get_absolute_path from cycode.cli.utils.string_utils import hash_string_to_sha256 from cycode.cyclient import logger @@ -67,6 +68,8 @@ def ignore_command( by_value: str, by_sha: str, by_path: str, by_rule: str, by_package: str, scan_type: str, is_global: bool ) -> None: """Ignores a specific value, path or rule ID.""" + add_breadcrumb('ignore') + if not by_value and not by_sha and not by_path and not by_rule and not by_package: raise click.ClickException('ignore by type is missing') diff --git a/cycode/cli/commands/report/report_command.py b/cycode/cli/commands/report/report_command.py index 7bfb73c6..9e92a64f 100644 --- a/cycode/cli/commands/report/report_command.py +++ b/cycode/cli/commands/report/report_command.py @@ -1,6 +1,7 @@ import click from cycode.cli.commands.report.sbom.sbom_command import sbom_command +from cycode.cli.sentry import add_breadcrumb from cycode.cli.utils.progress_bar import SBOM_REPORT_PROGRESS_BAR_SECTIONS, get_progress_bar @@ -15,5 +16,6 @@ def report_command( context: click.Context, ) -> int: """Generate report.""" + add_breadcrumb('report') context.obj['progress_bar'] = get_progress_bar(hidden=False, sections=SBOM_REPORT_PROGRESS_BAR_SECTIONS) return 1 diff --git a/cycode/cli/commands/report/sbom/path/path_command.py b/cycode/cli/commands/report/sbom/path/path_command.py index 8e88bd10..c52bc611 100644 --- a/cycode/cli/commands/report/sbom/path/path_command.py +++ b/cycode/cli/commands/report/sbom/path/path_command.py @@ -8,6 +8,7 @@ from cycode.cli.files_collector.path_documents import get_relevant_documents from cycode.cli.files_collector.sca.sca_code_scanner import perform_pre_scan_documents_actions from cycode.cli.files_collector.zip_documents import zip_documents +from cycode.cli.sentry import add_breadcrumb from cycode.cli.utils.get_api_client import get_report_cycode_client from cycode.cli.utils.progress_bar import SbomReportProgressBarSection @@ -16,6 +17,8 @@ @click.argument('path', nargs=1, type=click.Path(exists=True, resolve_path=True), required=True) @click.pass_context def path_command(context: click.Context, path: str) -> None: + add_breadcrumb('path') + client = get_report_cycode_client() report_parameters = context.obj['report_parameters'] output_format = report_parameters.output_format diff --git a/cycode/cli/commands/report/sbom/repository_url/repository_url_command.py b/cycode/cli/commands/report/sbom/repository_url/repository_url_command.py index 4f54cac1..189fd961 100644 --- a/cycode/cli/commands/report/sbom/repository_url/repository_url_command.py +++ b/cycode/cli/commands/report/sbom/repository_url/repository_url_command.py @@ -4,6 +4,7 @@ from cycode.cli.commands.report.sbom.common import create_sbom_report, send_report_feedback from cycode.cli.exceptions.handle_report_sbom_errors import handle_report_exception +from cycode.cli.sentry import add_breadcrumb from cycode.cli.utils.get_api_client import get_report_cycode_client from cycode.cli.utils.progress_bar import SbomReportProgressBarSection @@ -12,6 +13,8 @@ @click.argument('uri', nargs=1, type=str, required=True) @click.pass_context def repository_url_command(context: click.Context, uri: str) -> None: + add_breadcrumb('repository_url') + progress_bar = context.obj['progress_bar'] progress_bar.start() progress_bar.set_section_length(SbomReportProgressBarSection.PREPARE_LOCAL_FILES) diff --git a/cycode/cli/commands/report/sbom/sbom_command.py b/cycode/cli/commands/report/sbom/sbom_command.py index 870f4e0c..a938fd90 100644 --- a/cycode/cli/commands/report/sbom/sbom_command.py +++ b/cycode/cli/commands/report/sbom/sbom_command.py @@ -6,6 +6,7 @@ from cycode.cli.commands.report.sbom.path.path_command import path_command from cycode.cli.commands.report.sbom.repository_url.repository_url_command import repository_url_command from cycode.cli.config import config +from cycode.cli.sentry import add_breadcrumb from cycode.cyclient.report_client import ReportParameters @@ -64,6 +65,8 @@ def sbom_command( include_dev_dependencies: bool, ) -> int: """Generate SBOM report.""" + add_breadcrumb('sbom') + sbom_format_parts = format.split('-') if len(sbom_format_parts) != 2: raise click.ClickException('Invalid SBOM format.') diff --git a/cycode/cli/commands/scan/commit_history/commit_history_command.py b/cycode/cli/commands/scan/commit_history/commit_history_command.py index f7db9404..bfb57c29 100644 --- a/cycode/cli/commands/scan/commit_history/commit_history_command.py +++ b/cycode/cli/commands/scan/commit_history/commit_history_command.py @@ -2,6 +2,7 @@ from cycode.cli.commands.scan.code_scanner import scan_commit_range from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception +from cycode.cli.sentry import add_breadcrumb from cycode.cyclient import logger @@ -18,6 +19,8 @@ @click.pass_context def commit_history_command(context: click.Context, path: str, commit_range: str) -> None: try: + add_breadcrumb('commit_history') + logger.debug('Starting commit history scan process, %s', {'path': path, 'commit_range': commit_range}) scan_commit_range(context, path=path, commit_range=commit_range) except Exception as e: diff --git a/cycode/cli/commands/scan/path/path_command.py b/cycode/cli/commands/scan/path/path_command.py index 63182577..ec62b224 100644 --- a/cycode/cli/commands/scan/path/path_command.py +++ b/cycode/cli/commands/scan/path/path_command.py @@ -3,6 +3,7 @@ import click from cycode.cli.commands.scan.code_scanner import scan_disk_files +from cycode.cli.sentry import add_breadcrumb from cycode.cyclient import logger @@ -10,6 +11,8 @@ @click.argument('paths', nargs=-1, type=click.Path(exists=True, resolve_path=True), required=True) @click.pass_context def path_command(context: click.Context, paths: Tuple[str]) -> None: + add_breadcrumb('path') + progress_bar = context.obj['progress_bar'] progress_bar.start() diff --git a/cycode/cli/commands/scan/pre_commit/pre_commit_command.py b/cycode/cli/commands/scan/pre_commit/pre_commit_command.py index 657c839e..fa4b295a 100644 --- a/cycode/cli/commands/scan/pre_commit/pre_commit_command.py +++ b/cycode/cli/commands/scan/pre_commit/pre_commit_command.py @@ -11,6 +11,7 @@ get_diff_file_path, ) from cycode.cli.models import Document +from cycode.cli.sentry import add_breadcrumb from cycode.cli.utils.git_proxy import git_proxy from cycode.cli.utils.path_utils import ( get_path_by_os, @@ -22,6 +23,8 @@ @click.argument('ignored_args', nargs=-1, type=click.UNPROCESSED) @click.pass_context def pre_commit_command(context: click.Context, ignored_args: List[str]) -> None: + add_breadcrumb('pre_commit') + scan_type = context.obj['scan_type'] progress_bar = context.obj['progress_bar'] diff --git a/cycode/cli/commands/scan/pre_receive/pre_receive_command.py b/cycode/cli/commands/scan/pre_receive/pre_receive_command.py index 8aa2dbc9..3ad59bad 100644 --- a/cycode/cli/commands/scan/pre_receive/pre_receive_command.py +++ b/cycode/cli/commands/scan/pre_receive/pre_receive_command.py @@ -17,6 +17,7 @@ from cycode.cli.files_collector.repository_documents import ( calculate_pre_receive_commit_range, ) +from cycode.cli.sentry import add_breadcrumb from cycode.cli.utils.task_timer import TimeoutAfter from cycode.cyclient import logger @@ -26,6 +27,8 @@ @click.pass_context def pre_receive_command(context: click.Context, ignored_args: List[str]) -> None: try: + add_breadcrumb('pre_receive') + scan_type = context.obj['scan_type'] if scan_type != consts.SECRET_SCAN_TYPE: raise click.ClickException(f'Commit range scanning for {scan_type.upper()} is not supported') diff --git a/cycode/cli/commands/scan/repository/repository_command.py b/cycode/cli/commands/scan/repository/repository_command.py index cf560c26..cd6e9f71 100644 --- a/cycode/cli/commands/scan/repository/repository_command.py +++ b/cycode/cli/commands/scan/repository/repository_command.py @@ -9,6 +9,7 @@ from cycode.cli.files_collector.repository_documents import get_git_repository_tree_file_entries from cycode.cli.files_collector.sca.sca_code_scanner import perform_pre_scan_documents_actions from cycode.cli.models import Document +from cycode.cli.sentry import add_breadcrumb from cycode.cli.utils.path_utils import get_path_by_os from cycode.cli.utils.progress_bar import ScanProgressBarSection from cycode.cyclient import logger @@ -27,6 +28,8 @@ @click.pass_context def repository_command(context: click.Context, path: str, branch: str) -> None: try: + add_breadcrumb('repository') + logger.debug('Starting repository scan process, %s', {'path': path, 'branch': branch}) scan_type = context.obj['scan_type'] diff --git a/cycode/cli/commands/scan/scan_ci/scan_ci_command.py b/cycode/cli/commands/scan/scan_ci/scan_ci_command.py index 70383422..6d4fbd36 100644 --- a/cycode/cli/commands/scan/scan_ci/scan_ci_command.py +++ b/cycode/cli/commands/scan/scan_ci/scan_ci_command.py @@ -4,6 +4,7 @@ from cycode.cli.commands.scan.code_scanner import scan_commit_range from cycode.cli.commands.scan.scan_ci.ci_integrations import get_commit_range +from cycode.cli.sentry import add_breadcrumb # This command is not finished yet. It is not used in the codebase. @@ -14,4 +15,5 @@ ) @click.pass_context def scan_ci_command(context: click.Context) -> None: + add_breadcrumb('ci') scan_commit_range(context, path=os.getcwd(), commit_range=get_commit_range()) diff --git a/cycode/cli/commands/scan/scan_command.py b/cycode/cli/commands/scan/scan_command.py index cc97b577..d394f8c7 100644 --- a/cycode/cli/commands/scan/scan_command.py +++ b/cycode/cli/commands/scan/scan_command.py @@ -15,6 +15,7 @@ SCA_SKIP_RESTORE_DEPENDENCIES_FLAG, ) from cycode.cli.models import Severity +from cycode.cli.sentry import add_breadcrumb from cycode.cli.utils import scan_utils from cycode.cli.utils.get_api_client import get_scan_cycode_client @@ -124,6 +125,8 @@ def scan_command( sync: bool, ) -> int: """Scans for Secrets, IaC, SCA or SAST violations.""" + add_breadcrumb('scan') + if show_secret: context.obj['show_secret'] = show_secret else: @@ -155,6 +158,8 @@ def _sca_scan_to_context(context: click.Context, sca_scan_user_selected: List[st @scan_command.result_callback() @click.pass_context def finalize(context: click.Context, *_, **__) -> None: + add_breadcrumb('scan_finalize') + progress_bar = context.obj.get('progress_bar') if progress_bar: progress_bar.stop() diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 4e7b4556..27730720 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -1,4 +1,5 @@ PROGRAM_NAME = 'cycode' +APP_NAME = 'CycodeCLI' CLI_CONTEXT_SETTINGS = { 'terminal_width': 10**9, 'max_content_width': 10**9, @@ -142,6 +143,14 @@ SCAN_BATCH_MAX_PARALLEL_SCANS = 5 SCAN_BATCH_SCANS_PER_CPU = 1 +# sentry +SENTRY_DSN = 'https://5e26b304b30ced3a34394b6f81f1076d@o1026942.ingest.us.sentry.io/4507543840096256' +SENTRY_DEBUG = False +SENTRY_SAMPLE_RATE = 1.0 +SENTRY_SEND_DEFAULT_PII = False +SENTRY_INCLUDE_LOCAL_VARIABLES = False +SENTRY_MAX_REQUEST_BODY_SIZE = 'never' + # report with polling REPORT_POLLING_WAIT_INTERVAL_IN_SECONDS = 5 DEFAULT_REPORT_POLLING_TIMEOUT_IN_SECONDS = 600 diff --git a/cycode/cli/exceptions/handle_report_sbom_errors.py b/cycode/cli/exceptions/handle_report_sbom_errors.py index 21f24bd2..5ce117e1 100644 --- a/cycode/cli/exceptions/handle_report_sbom_errors.py +++ b/cycode/cli/exceptions/handle_report_sbom_errors.py @@ -5,6 +5,7 @@ from cycode.cli.exceptions import custom_exceptions from cycode.cli.models import CliError, CliErrors from cycode.cli.printers import ConsolePrinter +from cycode.cli.sentry import capture_exception def handle_report_exception(context: click.Context, err: Exception) -> Optional[CliError]: @@ -42,4 +43,6 @@ def handle_report_exception(context: click.Context, err: Exception) -> Optional[ if isinstance(err, click.ClickException): raise err + capture_exception(err) + raise click.ClickException(str(err)) diff --git a/cycode/cli/exceptions/handle_scan_errors.py b/cycode/cli/exceptions/handle_scan_errors.py index c59ffe8a..6e0948f9 100644 --- a/cycode/cli/exceptions/handle_scan_errors.py +++ b/cycode/cli/exceptions/handle_scan_errors.py @@ -5,6 +5,7 @@ from cycode.cli.exceptions import custom_exceptions from cycode.cli.models import CliError, CliErrors from cycode.cli.printers import ConsolePrinter +from cycode.cli.sentry import capture_exception from cycode.cli.utils.git_proxy import git_proxy @@ -69,13 +70,14 @@ def handle_scan_exception( ConsolePrinter(context).print_error(error) return None - unknown_error = CliError(code='unknown_error', message=str(e)) + if isinstance(e, click.ClickException): + raise e + + capture_exception(e) + unknown_error = CliError(code='unknown_error', message=str(e)) if return_exception: return unknown_error - if isinstance(e, click.ClickException): - raise e - ConsolePrinter(context).print_error(unknown_error) exit(1) diff --git a/cycode/cli/main.py b/cycode/cli/main.py index dd2d1fa7..d312723e 100644 --- a/cycode/cli/main.py +++ b/cycode/cli/main.py @@ -1,6 +1,7 @@ from multiprocessing import freeze_support from cycode.cli.commands.main_cli import main_cli +from cycode.cli.sentry import add_breadcrumb, init_sentry if __name__ == '__main__': # DO NOT REMOVE OR MOVE THIS LINE @@ -8,4 +9,7 @@ # see https://pyinstaller.org/en/latest/common-issues-and-pitfalls.html#multi-processing freeze_support() + init_sentry() + add_breadcrumb('cycode') + main_cli() diff --git a/cycode/cli/models.py b/cycode/cli/models.py index bccd4e76..08b812bc 100644 --- a/cycode/cli/models.py +++ b/cycode/cli/models.py @@ -62,6 +62,7 @@ class CliError(NamedTuple): class CliResult(NamedTuple): success: bool message: str + data: Optional[Dict[str, any]] = None class LocalScanResult(NamedTuple): diff --git a/cycode/cli/printers/json_printer.py b/cycode/cli/printers/json_printer.py index 187a1bf8..b682b8c7 100644 --- a/cycode/cli/printers/json_printer.py +++ b/cycode/cli/printers/json_printer.py @@ -13,7 +13,7 @@ class JsonPrinter(PrinterBase): def print_result(self, result: CliResult) -> None: - result = {'result': result.success, 'message': result.message} + result = {'result': result.success, 'message': result.message, 'data': result.data} click.echo(self.get_data_json(result)) diff --git a/cycode/cli/printers/text_printer.py b/cycode/cli/printers/text_printer.py index 1e2babd2..0b503207 100644 --- a/cycode/cli/printers/text_printer.py +++ b/cycode/cli/printers/text_printer.py @@ -27,6 +27,13 @@ def print_result(self, result: CliResult) -> None: click.secho(result.message, fg=color) + if not result.data: + return + + click.secho('\nAdditional data:', fg=color) + for name, value in result.data.items(): + click.secho(f'- {name}: {value}', fg=color) + def print_error(self, error: CliError) -> None: click.secho(error.message, fg=self.RED_COLOR_NAME) diff --git a/cycode/cli/sentry.py b/cycode/cli/sentry.py new file mode 100644 index 00000000..ea6ffd49 --- /dev/null +++ b/cycode/cli/sentry.py @@ -0,0 +1,99 @@ +import logging +from dataclasses import dataclass +from typing import Optional + +import sentry_sdk +from sentry_sdk.integrations.atexit import AtexitIntegration +from sentry_sdk.scrubber import DEFAULT_DENYLIST, EventScrubber + +from cycode import __version__ +from cycode.cli import consts +from cycode.cli.utils.jwt_utils import get_user_and_tenant_ids_from_access_token +from cycode.cyclient import logger + +# when Sentry is blocked on the machine, we want to keep clean output without retries warnings +logging.getLogger('urllib3.connectionpool').setLevel(logging.ERROR) +logging.getLogger('sentry_sdk').setLevel(logging.ERROR) + + +@dataclass +class _SentrySession: + user_id: Optional[str] = None + tenant_id: Optional[str] = None + correlation_id: Optional[str] = None + + +_SENTRY_SESSION = _SentrySession() +_DENY_LIST = [*DEFAULT_DENYLIST, 'access_token'] + + +def _get_sentry_release() -> str: + return f'{consts.APP_NAME}@{__version__}' + + +def _get_sentry_local_release() -> str: + return f'{consts.APP_NAME}@0.0.0' + + +_SENTRY_LOCAL_RELEASE = _get_sentry_local_release() + + +def _before_sentry_event_send(event: dict, _: dict) -> Optional[dict]: + if event.get('release') == _SENTRY_LOCAL_RELEASE: + logger.debug('Dropping Sentry event due to local development setup') + return None + + return event + + +def init_sentry() -> None: + sentry_sdk.init( + dsn=consts.SENTRY_DSN, + debug=consts.SENTRY_DEBUG, + release=_get_sentry_release(), + before_send=_before_sentry_event_send, + sample_rate=consts.SENTRY_SAMPLE_RATE, + send_default_pii=consts.SENTRY_SEND_DEFAULT_PII, + include_local_variables=consts.SENTRY_INCLUDE_LOCAL_VARIABLES, + max_request_body_size=consts.SENTRY_MAX_REQUEST_BODY_SIZE, + event_scrubber=EventScrubber(denylist=_DENY_LIST, recursive=True), + integrations=[ + AtexitIntegration(lambda _, __: None) # disable output to stderr about pending events + ], + ) + sentry_sdk.set_user(None) + + +def setup_scope_from_access_token(access_token: Optional[str]) -> None: + if not access_token: + return + + user_id, tenant_id = get_user_and_tenant_ids_from_access_token(access_token) + + _SENTRY_SESSION.user_id = user_id + _SENTRY_SESSION.tenant_id = tenant_id + + _setup_scope(user_id, tenant_id, _SENTRY_SESSION.correlation_id) + + +def add_correlation_id_to_scope(correlation_id: str) -> None: + _setup_scope(_SENTRY_SESSION.user_id, _SENTRY_SESSION.tenant_id, correlation_id) + + +def _setup_scope(user_id: str, tenant_id: str, correlation_id: Optional[str] = None) -> None: + scope = sentry_sdk.Scope.get_current_scope() + sentry_sdk.set_tag('tenant_id', tenant_id) + + user = {'id': user_id, 'tenant_id': tenant_id} + if correlation_id: + user['correlation_id'] = correlation_id + + scope.set_user(user) + + +def capture_exception(exception: BaseException) -> None: + sentry_sdk.capture_exception(exception) + + +def add_breadcrumb(message: str, category: str = 'cli') -> None: + sentry_sdk.add_breadcrumb(category=category, message=message, level='info') diff --git a/cycode/cli/user_settings/credentials_manager.py b/cycode/cli/user_settings/credentials_manager.py index c302fc96..ad380e8a 100644 --- a/cycode/cli/user_settings/credentials_manager.py +++ b/cycode/cli/user_settings/credentials_manager.py @@ -3,6 +3,7 @@ from typing import Optional, Tuple from cycode.cli.config import CYCODE_CLIENT_ID_ENV_VAR_NAME, CYCODE_CLIENT_SECRET_ENV_VAR_NAME +from cycode.cli.sentry import setup_scope_from_access_token from cycode.cli.user_settings.base_file_manager import BaseFileManager from cycode.cli.user_settings.jwt_creator import JwtCreator @@ -52,6 +53,8 @@ def get_access_token(self) -> Tuple[Optional[str], Optional[float], Optional[Jwt if hashed_creator: creator = JwtCreator(hashed_creator) + setup_scope_from_access_token(access_token) + return access_token, expires_in, creator def update_access_token( @@ -64,5 +67,7 @@ def update_access_token( } self.write_content_to_file(file_content_to_update) + setup_scope_from_access_token(access_token) + def get_filename(self) -> str: return os.path.join(self.HOME_PATH, self.CYCODE_HIDDEN_DIRECTORY, self.FILE_NAME) diff --git a/cycode/cli/utils/jwt_utils.py b/cycode/cli/utils/jwt_utils.py new file mode 100644 index 00000000..743570e2 --- /dev/null +++ b/cycode/cli/utils/jwt_utils.py @@ -0,0 +1,14 @@ +from typing import Tuple + +import jwt + + +def get_user_and_tenant_ids_from_access_token(access_token: str) -> Tuple[str, str]: + payload = jwt.decode(access_token, options={'verify_signature': False}) + user_id = payload.get('userId') + tenant_id = payload.get('tenantId') + + if not user_id or not tenant_id: + raise ValueError('Invalid access token') + + return user_id, tenant_id diff --git a/cycode/cyclient/headers.py b/cycode/cyclient/headers.py index cc0444fb..c6983d32 100644 --- a/cycode/cyclient/headers.py +++ b/cycode/cyclient/headers.py @@ -3,6 +3,8 @@ from uuid import uuid4 from cycode import __version__ +from cycode.cli import consts +from cycode.cli.sentry import add_correlation_id_to_scope from cycode.cli.user_settings.configuration_manager import ConfigurationManager from cycode.cyclient import logger @@ -12,7 +14,6 @@ def get_cli_user_agent() -> str: Example: CycodeCLI/0.2.3 (OS: Darwin; Arch: arm64; Python: 3.8.16; InstallID: *uuid4*) """ - app_name = 'CycodeCLI' version = __version__ os = platform.system() @@ -21,7 +22,7 @@ def get_cli_user_agent() -> str: install_id = ConfigurationManager().get_or_create_installation_id() - return f'{app_name}/{version} (OS: {os}; Arch: {arch}; Python: {python_version}; InstallID: {install_id})' + return f'{consts.APP_NAME}/{version} (OS: {os}; Arch: {arch}; Python: {python_version}; InstallID: {install_id})' class _CorrelationId: @@ -40,6 +41,8 @@ def get_correlation_id(self) -> str: self._id = str(uuid4()) logger.debug('Correlation ID: %s', self._id) + add_correlation_id_to_scope(self._id) + return self._id diff --git a/poetry.lock b/poetry.lock index 33f5470c..f8550921 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "altgraph" @@ -513,6 +513,26 @@ importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} packaging = ">=22.0" setuptools = ">=42.0.0" +[[package]] +name = "pyjwt" +version = "2.8.0" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "PyJWT-2.8.0-py3-none-any.whl", hash = "sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320"}, + {file = "PyJWT-2.8.0.tar.gz", hash = "sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de"}, +] + +[package.dependencies] +typing-extensions = {version = "*", markers = "python_version <= \"3.7\""} + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + [[package]] name = "pytest" version = "7.3.2" @@ -706,6 +726,56 @@ files = [ {file = "ruff-0.1.11.tar.gz", hash = "sha256:f9d4d88cb6eeb4dfe20f9f0519bd2eaba8119bde87c3d5065c541dbae2b5a2cb"}, ] +[[package]] +name = "sentry-sdk" +version = "2.8.0" +description = "Python client for Sentry (https://sentry.io)" +optional = false +python-versions = ">=3.6" +files = [ + {file = "sentry_sdk-2.8.0-py2.py3-none-any.whl", hash = "sha256:6051562d2cfa8087bb8b4b8b79dc44690f8a054762a29c07e22588b1f619bfb5"}, + {file = "sentry_sdk-2.8.0.tar.gz", hash = "sha256:aa4314f877d9cd9add5a0c9ba18e3f27f99f7de835ce36bd150e48a41c7c646f"}, +] + +[package.dependencies] +certifi = "*" +urllib3 = ">=1.26.11" + +[package.extras] +aiohttp = ["aiohttp (>=3.5)"] +anthropic = ["anthropic (>=0.16)"] +arq = ["arq (>=0.23)"] +asyncpg = ["asyncpg (>=0.23)"] +beam = ["apache-beam (>=2.12)"] +bottle = ["bottle (>=0.12.13)"] +celery = ["celery (>=3)"] +celery-redbeat = ["celery-redbeat (>=2)"] +chalice = ["chalice (>=1.16.0)"] +clickhouse-driver = ["clickhouse-driver (>=0.2.0)"] +django = ["django (>=1.8)"] +falcon = ["falcon (>=1.4)"] +fastapi = ["fastapi (>=0.79.0)"] +flask = ["blinker (>=1.1)", "flask (>=0.11)", "markupsafe"] +grpcio = ["grpcio (>=1.21.1)", "protobuf (>=3.8.0)"] +httpx = ["httpx (>=0.16.0)"] +huey = ["huey (>=2)"] +huggingface-hub = ["huggingface-hub (>=0.22)"] +langchain = ["langchain (>=0.0.210)"] +loguru = ["loguru (>=0.5)"] +openai = ["openai (>=1.0.0)", "tiktoken (>=0.3.0)"] +opentelemetry = ["opentelemetry-distro (>=0.35b0)"] +opentelemetry-experimental = ["opentelemetry-instrumentation-aio-pika (==0.46b0)", "opentelemetry-instrumentation-aiohttp-client (==0.46b0)", "opentelemetry-instrumentation-aiopg (==0.46b0)", "opentelemetry-instrumentation-asgi (==0.46b0)", "opentelemetry-instrumentation-asyncio (==0.46b0)", "opentelemetry-instrumentation-asyncpg (==0.46b0)", "opentelemetry-instrumentation-aws-lambda (==0.46b0)", "opentelemetry-instrumentation-boto (==0.46b0)", "opentelemetry-instrumentation-boto3sqs (==0.46b0)", "opentelemetry-instrumentation-botocore (==0.46b0)", "opentelemetry-instrumentation-cassandra (==0.46b0)", "opentelemetry-instrumentation-celery (==0.46b0)", "opentelemetry-instrumentation-confluent-kafka (==0.46b0)", "opentelemetry-instrumentation-dbapi (==0.46b0)", "opentelemetry-instrumentation-django (==0.46b0)", "opentelemetry-instrumentation-elasticsearch (==0.46b0)", "opentelemetry-instrumentation-falcon (==0.46b0)", "opentelemetry-instrumentation-fastapi (==0.46b0)", "opentelemetry-instrumentation-flask (==0.46b0)", "opentelemetry-instrumentation-grpc (==0.46b0)", "opentelemetry-instrumentation-httpx (==0.46b0)", "opentelemetry-instrumentation-jinja2 (==0.46b0)", "opentelemetry-instrumentation-kafka-python (==0.46b0)", "opentelemetry-instrumentation-logging (==0.46b0)", "opentelemetry-instrumentation-mysql (==0.46b0)", "opentelemetry-instrumentation-mysqlclient (==0.46b0)", "opentelemetry-instrumentation-pika (==0.46b0)", "opentelemetry-instrumentation-psycopg (==0.46b0)", "opentelemetry-instrumentation-psycopg2 (==0.46b0)", "opentelemetry-instrumentation-pymemcache (==0.46b0)", "opentelemetry-instrumentation-pymongo (==0.46b0)", "opentelemetry-instrumentation-pymysql (==0.46b0)", "opentelemetry-instrumentation-pyramid (==0.46b0)", "opentelemetry-instrumentation-redis (==0.46b0)", "opentelemetry-instrumentation-remoulade (==0.46b0)", "opentelemetry-instrumentation-requests (==0.46b0)", "opentelemetry-instrumentation-sklearn (==0.46b0)", "opentelemetry-instrumentation-sqlalchemy (==0.46b0)", "opentelemetry-instrumentation-sqlite3 (==0.46b0)", "opentelemetry-instrumentation-starlette (==0.46b0)", "opentelemetry-instrumentation-system-metrics (==0.46b0)", "opentelemetry-instrumentation-threading (==0.46b0)", "opentelemetry-instrumentation-tornado (==0.46b0)", "opentelemetry-instrumentation-tortoiseorm (==0.46b0)", "opentelemetry-instrumentation-urllib (==0.46b0)", "opentelemetry-instrumentation-urllib3 (==0.46b0)", "opentelemetry-instrumentation-wsgi (==0.46b0)"] +pure-eval = ["asttokens", "executing", "pure-eval"] +pymongo = ["pymongo (>=3.1)"] +pyspark = ["pyspark (>=2.4.4)"] +quart = ["blinker (>=1.1)", "quart (>=0.16.1)"] +rq = ["rq (>=0.6)"] +sanic = ["sanic (>=0.8)"] +sqlalchemy = ["sqlalchemy (>=1.2)"] +starlette = ["starlette (>=0.19.1)"] +starlite = ["starlite (>=1.48)"] +tornado = ["tornado (>=6)"] + [[package]] name = "setuptools" version = "68.0.0" @@ -822,4 +892,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = ">=3.7,<3.13" -content-hash = "fcbb52402ab9e081fbc204e4224e1bc0ec4466ea10f27597cb6259d60b473229" +content-hash = "1cc189cf2949bc14816bd8afbbb33bf980ad15a3f203fbe1f811cb4bc1bbd052" diff --git a/pyproject.toml b/pyproject.toml index e4d7995e..3bdbffa7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,8 @@ binaryornot = ">=0.4.4,<0.5.0" texttable = ">=1.6.7,<1.8.0" requests = ">=2.24,<3.0" urllib3 = "1.26.18" # lock v1 to avoid issues with openssl and old Python versions (<3.9.11) on macOS +sentry-sdk = ">=2.8.0,<3.0" +pyjwt = ">=2.8.0,<3.0" [tool.poetry.group.test.dependencies] mock = ">=4.0.3,<4.1.0" diff --git a/tests/conftest.py b/tests/conftest.py index 821a0289..6fa50f55 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,8 @@ from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient from cycode.cyclient.scan_client import ScanClient -_EXPECTED_API_TOKEN = 'someJWT' +# not real JWT with userId and tenantId fields +_EXPECTED_API_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJ1c2VySWQiOiJibGFibGEiLCJ0ZW5hbnRJZCI6ImJsYWJsYSJ9.8RfoWBfciuj8nwc7UB8uOUJchVuaYpYlgf1G2QHiWTk' # noqa: E501 _CLIENT_ID = 'b1234568-0eaa-1234-beb8-6f0c12345678' _CLIENT_SECRET = 'a12345a-42b2-1234-3bdd-c0130123456'