From f7a16164657311eb2689ebff0d680510a4d91bee Mon Sep 17 00:00:00 2001 From: morsa4406 <104304449+morsa4406@users.noreply.github.com> Date: Tue, 27 Jun 2023 16:30:07 +0300 Subject: [PATCH 001/257] CM-24634 - Add dependency paths column on SCA table output (#127) --- cycode/cli/printers/sca_table_printer.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/cycode/cli/printers/sca_table_printer.py b/cycode/cli/printers/sca_table_printer.py index 34130607..ca441ca6 100644 --- a/cycode/cli/printers/sca_table_printer.py +++ b/cycode/cli/printers/sca_table_printer.py @@ -19,7 +19,8 @@ 'Ecosystem', 'Dependency Name', 'Direct Dependency', - 'Development Dependency' + 'Development Dependency', + 'Dependency Paths', ] @@ -107,13 +108,28 @@ def set_table_width(headers: List[str], text_table: Texttable) -> None: def _print_summary_issues(detections: List, title: str) -> None: click.echo(f'⛔ Found {len(detections)} issues of type: {click.style(title, bold=True)}') + @staticmethod + def _shortcut_dependency_paths(dependency_paths: str) -> str: + dependencies = dependency_paths.split(' -> ') + + if len(dependencies) < 2: + return dependencies[0] + + return f'{dependencies[0]} -> ... -> {dependencies[-1]}' + def _get_common_detection_fields(self, detection: Detection) -> List[str]: + dependency_paths = 'N/A' + dependency_paths_raw = detection.detection_details.get('dependency_paths') + if dependency_paths_raw: + dependency_paths = self._shortcut_dependency_paths(dependency_paths_raw) + row = [ detection.detection_details.get('file_name'), detection.detection_details.get('ecosystem'), detection.detection_details.get('package_name'), detection.detection_details.get('is_direct_dependency_str'), - detection.detection_details.get('is_dev_dependency_str') + detection.detection_details.get('is_dev_dependency_str'), + dependency_paths, ] if self._is_git_repository(): From a4b13fb007b7c23ada309feea62a712ba42c3770 Mon Sep 17 00:00:00 2001 From: anna-aleksandrowicz <135120640+anna-aleksandrowicz@users.noreply.github.com> Date: Wed, 28 Jun 2023 00:06:34 +0300 Subject: [PATCH 002/257] Improve README (#125) --- README.md | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 370001e7..f2fbe808 100644 --- a/README.md +++ b/README.md @@ -48,14 +48,14 @@ This guide will guide you through both installation and usage. - The Cycode CLI application requires Python version 3.7 or later. - Use the [`cycode auth` command](#use-auth-command) to authenticate to Cycode with the CLI - - Alternatively, a Cycode Client ID and Client Secret Key can be acquired using the steps from the [Service Account Token](https://docs.cycode.com/reference/creating-a-service-account-access-token) and [Personal Access Token](https://docs.cycode.com/reference/creating-a-personal-access-token-1) pages for details on obtaining these values. + - Alternatively, you can obtain a Cycode Client ID and Client Secret Key by following the steps detailed in the [Service Account Token](https://docs.cycode.com/reference/creating-a-service-account-access-token) and [Personal Access Token](https://docs.cycode.com/reference/creating-a-personal-access-token-1) pages, which contain details on obtaining these values. # Installation -The following installation steps are applicable on both Windows and UNIX / Linux operating systems. +The following installation steps are applicable to both Windows and UNIX / Linux operating systems. > :memo: **Note**
-> The following steps assume the use of `python3` and `pip3` for Python-related commands, but some systems may instead use the `python` and `pip` commands, depending on your Python environment’s configuration. +> The following steps assume the use of `python3` and `pip3` for Python-related commands; however, some systems may instead use the `python` and `pip` commands, depending on your Python environment’s configuration. ## Install Cycode CLI @@ -75,7 +75,7 @@ To install the Cycode CLI application on your local machine, perform the followi - [cycode configure](#use-configure-command) - Add them to your [environment variables](#add-to-environment-variables) -### Use auth Command +### Useing the Auth Command > :memo: **Note**
> This is the **recommended** method for setting up your local machine to authenticate with Cycode CLI. @@ -88,20 +88,20 @@ To install the Cycode CLI application on your local machine, perform the followi ![Cycode login](https://raw.githubusercontent.com/cycodehq-public/cycode-cli/main/images/cycode_login.png) -3. Enter you login credentials on this page and log in. +3. Enter your login credentials on this page and log in. -4. You will eventually be taken to this page, where you will be asked to choose the business group you want to authorize Cycode with (if applicable): +4. You will eventually be taken to the page below, where you'll be asked to choose the business group you want to authorize Cycode with (if applicable): ![authorize CLI](https://raw.githubusercontent.com/cycodehq-public/cycode-cli/main/images/authorize_cli.png) > :memo: **Note**
> This will be the default method for authenticating with the Cycode CLI. -5. Click the **Allow** button to authorize the Cycode CLI on the chosen business group. +5. Click the **Allow** button to authorize the Cycode CLI on the selected business group. ![allow CLI](https://raw.githubusercontent.com/cycodehq-public/cycode-cli/main/images/allow_cli.png) -6. Once done, you will see the following screen, if it was successfully selected: +6. Once completed, you'll see the following screen, if it was selected successfully: ![successfully auth](https://raw.githubusercontent.com/cycodehq-public/cycode-cli/main/images/successfully_auth.png) @@ -111,10 +111,10 @@ To install the Cycode CLI application on your local machine, perform the followi Successfully logged into cycode ``` -### Use configure Command +### Using the Configure Command > :memo: **Note**
-> If you already setup your Cycode client ID and client secret through the Linux or Windows environment variables, those credentials will take precedent over this method +> If you already set up your Cycode client ID and client secret through the Linux or Windows environment variables, those credentials will take precedent over this method. 1. Type the following command into your terminal/command line window: @@ -139,13 +139,13 @@ To install the Cycode CLI application on your local machine, perform the followi cycode client secret []: c1e24929-xxxx-xxxx-xxxx-8b08c1839a2e ``` -4. If the values were entered successfully, you will see the following message: +4. If the values were entered successfully, you'll see the following message: ```bash Successfully configured CLI credentials! ``` -If you go into the `.cycode` folder under you user folder, you will find these credentials were created and placed in the `credentials.yaml` file in that folder. +If you go into the `.cycode` folder under your user folder, you'll find these credentials were created and placed in the `credentials.yaml` file in that folder. ### Add to Environment Variables @@ -174,9 +174,11 @@ export CYCODE_CLIENT_SECRET={your Cycode Secret Key} ![](https://raw.githubusercontent.com/cycodehq-public/cycode-cli/main/images/image4.png) +5. Insert the cycode.exe into the path to complete the installation. + ## Install Pre-Commit Hook -Cycode’s pre-commit hook can be set up within your local repository so that the Cycode CLI application will automatically identify any issues with your code before you commit it to your codebase. +Cycode’s pre-commit hook can be set up within your local repository so that the Cycode CLI application will identify any issues with your code automatically before you commit it to your codebase. Perform the following steps to install the pre-commit hook: @@ -204,10 +206,10 @@ repos: `pre-commit install` > :memo: **Note**
-> Successful hook installation will result in the message:
+> A successful hook installation will result in the message:
`Pre-commit installed at .git/hooks/pre-commit` -# Cycode Command +# Cycode CLI Commands The following are the options and commands available with the Cycode CLI application: @@ -251,7 +253,7 @@ The Cycode CLI application offers several types of scans so that you can choose ## Repository Scan -A repository scan examines an entire local repository for any exposed secrets or insecure misconfigurations. This more holistic scan type looks at everything: the current state of your repository and its commit history. It will look not only for currently exposed secrets within the repository but previously deleted secrets as well. +A repository scan examines an entire local repository for any exposed secrets or insecure misconfigurations. This more holistic scan type looks at everything: the current state of your repository and its commit history. It will look not only for secrets that are currently exposed within the repository but previously deleted secrets as well. To execute a full repository scan, execute the following: From c7f57819eb0196cbfc86ec5ee8d6e923cafdf5d2 Mon Sep 17 00:00:00 2001 From: morsa4406 <104304449+morsa4406@users.noreply.github.com> Date: Wed, 28 Jun 2023 14:23:08 +0300 Subject: [PATCH 003/257] CM-24707 - Fix dependency paths to add continuation dependencies sign for the list greater than 2 (#131) * CM-24707 [SCA-CLI] fixing dependency paths to add continuation dependencies for list greater than 2 * CM-24707 - Fix dependency paths to add continuation dependencies sign for the list greater than 2 * CM-24707 - Fix dependency paths to add continuation dependencies sign for the list greater than 2 * CM-24707 - Fix dependency paths to add continuation dependencies sign for the list greater than 2 * CM-24707 - Fix dependency paths to add continuation dependencies sign for the list greater than 2 --- cycode/cli/consts.py | 5 +++++ cycode/cli/printers/sca_table_printer.py | 14 +++----------- cycode/cli/utils/string_utils.py | 17 +++++++++++++++++ tests/utils/test_string_utils.py | 7 +++++++ 4 files changed, 32 insertions(+), 11 deletions(-) create mode 100644 tests/utils/test_string_utils.py diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 3b0aaafe..f37bbdef 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -126,3 +126,8 @@ LICENSE_COMPLIANCE_POLICY_ID = '8f681450-49e1-4f7e-85b7-0c8fe84b3a35' PACKAGE_VULNERABILITY_POLICY_ID = '9369d10a-9ac0-48d3-9921-5de7fe9a37a7' + +# Shortcut dependency paths by remove all middle depndencies between direct dependency and influence/vulnerable dependency. +# Example: A -> B -> C +# Result: A -> ... -> C +SCA_SHORTCUT_DEPENDENCY_PATHS = 2 diff --git a/cycode/cli/printers/sca_table_printer.py b/cycode/cli/printers/sca_table_printer.py index ca441ca6..7feac6b1 100644 --- a/cycode/cli/printers/sca_table_printer.py +++ b/cycode/cli/printers/sca_table_printer.py @@ -7,6 +7,7 @@ from cycode.cli.consts import LICENSE_COMPLIANCE_POLICY_ID, PACKAGE_VULNERABILITY_POLICY_ID from cycode.cli.models import DocumentDetections, Detection from cycode.cli.printers.base_table_printer import BaseTablePrinter +from cycode.cli.utils.string_utils import shortcut_dependency_paths SEVERITY_COLUMN = 'Severity' LICENSE_COLUMN = 'License' @@ -108,20 +109,11 @@ def set_table_width(headers: List[str], text_table: Texttable) -> None: def _print_summary_issues(detections: List, title: str) -> None: click.echo(f'⛔ Found {len(detections)} issues of type: {click.style(title, bold=True)}') - @staticmethod - def _shortcut_dependency_paths(dependency_paths: str) -> str: - dependencies = dependency_paths.split(' -> ') - - if len(dependencies) < 2: - return dependencies[0] - - return f'{dependencies[0]} -> ... -> {dependencies[-1]}' - def _get_common_detection_fields(self, detection: Detection) -> List[str]: dependency_paths = 'N/A' dependency_paths_raw = detection.detection_details.get('dependency_paths') if dependency_paths_raw: - dependency_paths = self._shortcut_dependency_paths(dependency_paths_raw) + dependency_paths = shortcut_dependency_paths(dependency_paths_raw) row = [ detection.detection_details.get('file_name'), @@ -129,7 +121,7 @@ def _get_common_detection_fields(self, detection: Detection) -> List[str]: detection.detection_details.get('package_name'), detection.detection_details.get('is_direct_dependency_str'), detection.detection_details.get('is_dev_dependency_str'), - dependency_paths, + dependency_paths ] if self._is_git_repository(): diff --git a/cycode/cli/utils/string_utils.py b/cycode/cli/utils/string_utils.py index 0e7d0c23..f301f9ac 100644 --- a/cycode/cli/utils/string_utils.py +++ b/cycode/cli/utils/string_utils.py @@ -6,6 +6,8 @@ from sys import getsizeof from binaryornot.check import is_binary_string +from cycode.cli.consts import SCA_SHORTCUT_DEPENDENCY_PATHS + def obfuscate_text(text: str) -> str: match_len = len(text) @@ -47,3 +49,18 @@ def generate_random_string(string_len: int): def get_position_in_line(text: str, position: int) -> int: return position - text.rfind('\n', 0, position) - 1 + + +def shortcut_dependency_paths(dependency_paths_list: str) -> str: + separate_dependency_paths_list = dependency_paths_list.split(',') + result = '' + for dependency_paths in separate_dependency_paths_list: + dependency_paths = dependency_paths.strip().rstrip() + dependencies = dependency_paths.split(' -> ') + if len(dependencies) <= SCA_SHORTCUT_DEPENDENCY_PATHS: + result += dependency_paths + else: + result += f'{dependencies[0]} -> ... -> {dependencies[-1]}' + result += '\n\n' + + return result.rstrip().rstrip(',') diff --git a/tests/utils/test_string_utils.py b/tests/utils/test_string_utils.py new file mode 100644 index 00000000..7b20fd4c --- /dev/null +++ b/tests/utils/test_string_utils.py @@ -0,0 +1,7 @@ +from cycode.cli.utils.string_utils import shortcut_dependency_paths + + +def test_shortcut_dependency_paths_list_single_dependencies(): + dependency_paths = "A, A -> B, A -> B -> C" + expected_result = "A\n\nA -> B\n\nA -> ... -> C" + assert shortcut_dependency_paths(dependency_paths) == expected_result From 657c7fc2497dccbdfb08981508d9aa450b5a143b Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 5 Jul 2023 11:54:50 +0200 Subject: [PATCH 004/257] CM-22718 - Support scan in batches with progress bar (#129) --- cycode/cli/auth/auth_command.py | 4 +- cycode/cli/code_scanner.py | 947 +++++++++++------- cycode/cli/consts.py | 12 +- cycode/cli/main.py | 47 +- cycode/cli/models.py | 11 +- cycode/cli/printers/base_printer.py | 9 +- cycode/cli/printers/base_table_printer.py | 22 +- cycode/cli/printers/console_printer.py | 7 +- cycode/cli/printers/json_printer.py | 20 +- cycode/cli/printers/sca_table_printer.py | 28 +- cycode/cli/printers/table_printer.py | 26 +- cycode/cli/printers/text_printer.py | 45 +- cycode/cli/utils/enum_utils.py | 7 + cycode/cli/utils/path_utils.py | 8 +- cycode/cli/utils/progress_bar.py | 240 +++++ cycode/cli/utils/scan_batch.py | 73 ++ cycode/cli/utils/scan_utils.py | 10 +- cycode/cyclient/cycode_client_base.py | 10 +- cycode/cyclient/cycode_token_based_client.py | 7 +- cycode/cyclient/scan_client.py | 15 +- .../scan_config/scan_config_creator.py | 8 +- poetry.lock | 109 +- pyproject.toml | 1 - tests/cli/test_main.py | 6 +- tests/cyclient/test_scan_client.py | 9 +- 25 files changed, 1131 insertions(+), 550 deletions(-) create mode 100644 cycode/cli/utils/enum_utils.py create mode 100644 cycode/cli/utils/progress_bar.py create mode 100644 cycode/cli/utils/scan_batch.py diff --git a/cycode/cli/auth/auth_command.py b/cycode/cli/auth/auth_command.py index f7605965..37b6c3f6 100644 --- a/cycode/cli/auth/auth_command.py +++ b/cycode/cli/auth/auth_command.py @@ -48,14 +48,14 @@ def authorization_check(context: click.Context): return printer.print_result(passed_auth_check_res) except (NetworkError, HttpUnauthorizedError): if context.obj['verbose']: - click.secho(f'Error: {traceback.format_exc()}', fg='red', nl=False) + click.secho(f'Error: {traceback.format_exc()}', fg='red') return printer.print_result(failed_auth_check_res) def _handle_exception(context: click.Context, e: Exception): if context.obj['verbose']: - click.secho(f'Error: {traceback.format_exc()}', fg='red', nl=False) + click.secho(f'Error: {traceback.format_exc()}', fg='red') errors: CliErrors = { AuthProcessError: CliError( diff --git a/cycode/cli/code_scanner.py b/cycode/cli/code_scanner.py index 0c0c996d..f2219716 100644 --- a/cycode/cli/code_scanner.py +++ b/cycode/cli/code_scanner.py @@ -7,27 +7,36 @@ import traceback from platform import platform from uuid import uuid4, UUID +from typing import TYPE_CHECKING, Callable, List, Optional, Dict, Tuple + from git import Repo, NULL_TREE, InvalidGitRepositoryError from sys import getsizeof -from halo import Halo - from cycode.cli.printers import ConsolePrinter -from cycode.cli.models import Document, DocumentDetections, Severity, CliError, CliErrors +from cycode.cli.models import Document, DocumentDetections, Severity, CliError, CliErrors, LocalScanResult from cycode.cli.ci_integrations import get_commit_range -from cycode.cli.consts import * +from cycode.cli import consts from cycode.cli.config import configuration_manager +from cycode.cli.utils.progress_bar import ProgressBarSection +from cycode.cli.utils.scan_utils import set_issue_detected from cycode.cli.utils.path_utils import is_sub_path, is_binary_file, get_file_size, get_relevant_files_in_path, \ get_path_by_os, get_file_content +from cycode.cli.utils.scan_batch import run_parallel_batched_scan from cycode.cli.utils.string_utils import get_content_size, is_binary_content from cycode.cli.utils.task_timer import TimeoutAfter from cycode.cli.utils import scan_utils from cycode.cli.user_settings.config_file_manager import ConfigFileManager from cycode.cli.zip_file import InMemoryZip -from cycode.cli.exceptions.custom_exceptions import * +from cycode.cli.exceptions import custom_exceptions from cycode.cli.helpers import sca_code_scanner from cycode.cyclient import logger -from cycode.cyclient.models import * +from cycode.cli.utils.progress_bar import logger as progress_bar_logger +from cycode.cyclient.models import ZippedFileScanResult, Detection, DetectionsPerFile, DetectionSchema + +if TYPE_CHECKING: + from cycode.cyclient.scan_client import ScanClient + from cycode.cyclient.models import ScanDetailsResponse + from cycode.cli.utils.progress_bar import BaseProgressBar start_scan_time = time.time() @@ -40,26 +49,40 @@ type=str, required=False) @click.pass_context -def scan_repository(context: click.Context, path, branch): +def scan_repository(context: click.Context, path: str, branch: str): """ Scan git repository including its history """ try: logger.debug('Starting repository scan process, %s', {'path': path, 'branch': branch}) - context.obj["path"] = path - monitor = context.obj.get("monitor") - scan_type = context.obj["scan_type"] - if monitor and scan_type != SCA_SCAN_TYPE: - raise click.ClickException(f"Monitor flag is currently supported for SCA scan type only") - - documents_to_scan = [ - Document(obj.path if monitor else get_path_by_os(os.path.join(path, obj.path)), - obj.data_stream.read().decode('utf-8', errors='replace')) - for obj - in get_git_repository_tree_file_entries(path, branch)] + + scan_type = context.obj['scan_type'] + monitor = context.obj.get('monitor') + if monitor and scan_type != consts.SCA_SCAN_TYPE: + raise click.ClickException(f'Monitor flag is currently supported for SCA scan type only') + + progress_bar = context.obj['progress_bar'] + + file_entries = list(get_git_repository_tree_file_entries(path, branch)) + progress_bar.set_section_length(ProgressBarSection.PREPARE_LOCAL_FILES, len(file_entries)) + + documents_to_scan = [] + for file in file_entries: + progress_bar.update(ProgressBarSection.PREPARE_LOCAL_FILES) + + if monitor: + path = file.path + else: + path = get_path_by_os(os.path.join(path, file.path)) + + documents_to_scan.append(Document(path, file.data_stream.read().decode('UTF-8', errors='replace'))) + documents_to_scan = exclude_irrelevant_documents_to_scan(context, documents_to_scan) - perform_pre_scan_documents_actions(context, scan_type, documents_to_scan, False) + + perform_pre_scan_documents_actions(context, scan_type, documents_to_scan, is_git_diff=False) + logger.debug('Found all relevant files for scanning %s', {'path': path, 'branch': branch}) - return scan_documents(context, documents_to_scan, is_git_diff=False, - scan_parameters=get_scan_parameters(context)) + return scan_documents( + context, documents_to_scan, is_git_diff=False, scan_parameters=get_scan_parameters(context, path) + ) except Exception as e: _handle_exception(context, e) @@ -86,27 +109,32 @@ def scan_repository_commit_history(context: click.Context, path: str, commit_ran def scan_commit_range(context: click.Context, path: str, commit_range: str, max_commits_count: Optional[int] = None): - scan_type = context.obj["scan_type"] - if scan_type not in COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES: - raise click.ClickException(f"Commit range scanning for {str.upper(scan_type)} is not supported") + scan_type = context.obj['scan_type'] + progress_bar = context.obj['progress_bar'] + + if scan_type not in consts.COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES: + raise click.ClickException(f'Commit range scanning for {str.upper(scan_type)} is not supported') - if scan_type == SCA_SCAN_TYPE: + if scan_type == consts.SCA_SCAN_TYPE: return scan_sca_commit_range(context, path, commit_range) documents_to_scan = [] commit_ids_to_scan = [] repo = Repo(path) - total_commits_count = repo.git.rev_list('--count', commit_range) + total_commits_count = int(repo.git.rev_list('--count', commit_range)) + logger.debug(f'Calculating diffs for {total_commits_count} commits in the commit range {commit_range}') + + progress_bar.set_section_length(ProgressBarSection.PREPARE_LOCAL_FILES, total_commits_count) + scanned_commits_count = 0 - logger.info(f'Calculating diffs for {total_commits_count} commits in the commit range {commit_range}') for commit in repo.iter_commits(rev=commit_range): if _does_reach_to_max_commits_to_scan_limit(commit_ids_to_scan, max_commits_count): - logger.info(f'Reached to max commits to scan count. Going to scan only {max_commits_count} last commits') + logger.debug(f'Reached to max commits to scan count. Going to scan only {max_commits_count} last commits') + progress_bar.update(ProgressBarSection.PREPARE_LOCAL_FILES, total_commits_count - scanned_commits_count) break - if _should_update_progress(scanned_commits_count): - logger.info(f'Calculated diffs for {scanned_commits_count} out of {total_commits_count} commits') + progress_bar.update(ProgressBarSection.PREPARE_LOCAL_FILES) commit_id = commit.hexsha commit_ids_to_scan.append(commit_id) @@ -114,18 +142,29 @@ def scan_commit_range(context: click.Context, path: str, commit_range: str, max_ diff = commit.diff(parent, create_patch=True, R=True) commit_documents_to_scan = [] for blob in diff: - doc = Document(get_path_by_os(os.path.join(path, get_diff_file_path(blob))), - blob.diff.decode('utf-8', errors='replace'), True, unique_id=commit_id) - commit_documents_to_scan.append(doc) - - logger.debug('Found all relevant files in commit %s', - {'path': path, 'commit_range': commit_range, 'commit_id': commit_id}) - commit_documents_to_scan = exclude_irrelevant_documents_to_scan(context, commit_documents_to_scan) - documents_to_scan.extend(commit_documents_to_scan) + blob_path = get_path_by_os(os.path.join(path, get_diff_file_path(blob))) + commit_documents_to_scan.append(Document( + path=blob_path, + content=blob.diff.decode('UTF-8', errors='replace'), + is_git_diff_format=True, + unique_id=commit_id + )) + + logger.debug( + 'Found all relevant files in commit %s', + { + 'path': path, + 'commit_range': commit_range, + 'commit_id': commit_id + } + ) + + documents_to_scan.extend(exclude_irrelevant_documents_to_scan(context, commit_documents_to_scan)) scanned_commits_count += 1 logger.debug('List of commit ids to scan, %s', {'commit_ids': commit_ids_to_scan}) - logger.info('Starting to scan commit range (It may take a few minutes)') + logger.debug('Starting to scan commit range (It may take a few minutes)') + return scan_documents(context, documents_to_scan, is_git_diff=True, is_commit_range=True) @@ -143,25 +182,32 @@ def scan_ci(context: click.Context): def scan_path(context: click.Context, path): """ Scan the files in the path supplied in the command """ logger.debug('Starting path scan process, %s', {'path': path}) - context.obj["path"] = path files_to_scan = get_relevant_files_in_path(path=path, exclude_patterns=["**/.git/**", "**/.cycode/**"]) files_to_scan = exclude_irrelevant_files(context, files_to_scan) logger.debug('Found all relevant files for scanning %s', {'path': path, 'file_to_scan_count': len(files_to_scan)}) - return scan_disk_files(context, files_to_scan) + return scan_disk_files(context, path, files_to_scan) @click.command() -@click.argument("ignored_args", nargs=-1, type=click.UNPROCESSED) +@click.argument('ignored_args', nargs=-1, type=click.UNPROCESSED) @click.pass_context def pre_commit_scan(context: click.Context, ignored_args: List[str]): """ Use this command to scan the content that was not committed yet """ scan_type = context.obj['scan_type'] - if scan_type == SCA_SCAN_TYPE: + progress_bar = context.obj['progress_bar'] + + if scan_type == consts.SCA_SCAN_TYPE: return scan_sca_pre_commit(context) - diff_files = Repo(os.getcwd()).index.diff("HEAD", create_patch=True, R=True) - documents_to_scan = [Document(get_path_by_os(get_diff_file_path(file)), get_diff_file_content(file)) - for file in diff_files] + diff_files = Repo(os.getcwd()).index.diff('HEAD', create_patch=True, R=True) + + progress_bar.set_section_length(ProgressBarSection.PREPARE_LOCAL_FILES, len(diff_files)) + + documents_to_scan = [] + for file in diff_files: + progress_bar.update(ProgressBarSection.PREPARE_LOCAL_FILES) + documents_to_scan.append(Document(get_path_by_os(get_diff_file_path(file)), get_diff_file_content(file))) + documents_to_scan = exclude_irrelevant_documents_to_scan(context, documents_to_scan) return scan_documents(context, documents_to_scan, is_git_diff=True) @@ -170,11 +216,11 @@ def pre_commit_scan(context: click.Context, ignored_args: List[str]): @click.argument("ignored_args", nargs=-1, type=click.UNPROCESSED) @click.pass_context def pre_receive_scan(context: click.Context, ignored_args: List[str]): - """ Use this command to scan commits on server side before pushing them to the repository """ + """ Use this command to scan commits on the server side before pushing them to the repository """ try: scan_type = context.obj['scan_type'] - if scan_type != SECRET_SCAN_TYPE: - raise click.ClickException(f"Commit range scanning for {str.upper(scan_type)} is not supported") + if scan_type != consts.SECRET_SCAN_TYPE: + raise click.ClickException(f'Commit range scanning for {scan_type.upper()} is not supported') if should_skip_pre_receive_scan(): logger.info( @@ -189,14 +235,16 @@ def pre_receive_scan(context: click.Context, ignored_args: List[str]): command_scan_type = context.info_name timeout = configuration_manager.get_pre_receive_command_timeout(command_scan_type) with TimeoutAfter(timeout): - if scan_type not in COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES: - raise click.ClickException(f"Commit range scanning for {str.upper(scan_type)} is not supported") + if scan_type not in consts.COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES: + raise click.ClickException(f'Commit range scanning for {scan_type.upper()} is not supported') branch_update_details = parse_pre_receive_input() commit_range = calculate_pre_receive_commit_range(branch_update_details) if not commit_range: - logger.info('No new commits found for pushed branch, %s', - {'branch_update_details': branch_update_details}) + logger.info( + 'No new commits found for pushed branch, %s', + {'branch_update_details': branch_update_details} + ) return max_commits_to_scan = configuration_manager.get_pre_receive_max_commits_to_scan_count(command_scan_type) @@ -208,7 +256,7 @@ def pre_receive_scan(context: click.Context, ignored_args: List[str]): def scan_sca_pre_commit(context: click.Context): scan_parameters = get_default_scan_parameters(context) - git_head_documents, pre_committed_documents = get_pre_commit_modified_documents() + git_head_documents, pre_committed_documents = get_pre_commit_modified_documents(context.obj['progress_bar']) git_head_documents = exclude_irrelevant_documents_to_scan(context, git_head_documents) pre_committed_documents = exclude_irrelevant_documents_to_scan(context, pre_committed_documents) sca_code_scanner.perform_pre_hook_range_scan_actions(git_head_documents, pre_committed_documents) @@ -218,11 +266,13 @@ def scan_sca_pre_commit(context: click.Context): def scan_sca_commit_range(context: click.Context, path: str, commit_range: str): - context.obj["path"] = path - scan_parameters = get_scan_parameters(context) + progress_bar = context.obj['progress_bar'] + + scan_parameters = get_scan_parameters(context, path) from_commit_rev, to_commit_rev = parse_commit_range(commit_range, path) - from_commit_documents, to_commit_documents = \ - get_commit_range_modified_documents(path, from_commit_rev, to_commit_rev) + from_commit_documents, to_commit_documents = get_commit_range_modified_documents( + progress_bar, path, from_commit_rev, to_commit_rev + ) from_commit_documents = exclude_irrelevant_documents_to_scan(context, from_commit_documents) to_commit_documents = exclude_irrelevant_documents_to_scan(context, to_commit_documents) sca_code_scanner.perform_pre_commit_range_scan_actions(path, from_commit_documents, from_commit_rev, @@ -232,249 +282,401 @@ def scan_sca_commit_range(context: click.Context, path: str, commit_range: str): scan_parameters=scan_parameters) -def scan_disk_files(context: click.Context, paths: List[str]): - scan_parameters = get_scan_parameters(context) +def scan_disk_files(context: click.Context, path: str, files_to_scan: List[str]): + scan_parameters = get_scan_parameters(context, path) scan_type = context.obj['scan_type'] + progress_bar = context.obj['progress_bar'] + is_git_diff = False + + progress_bar.set_section_length(ProgressBarSection.PREPARE_LOCAL_FILES, len(files_to_scan)) + documents: List[Document] = [] - for path in paths: - with open(path, "r", encoding="utf-8") as f: - content = f.read() - documents.append(Document(path, content, is_git_diff)) + for file in files_to_scan: + progress_bar.update(ProgressBarSection.PREPARE_LOCAL_FILES) + + with open(file, 'r', encoding='UTF-8') as f: + try: + documents.append(Document(file, f.read(), is_git_diff)) + except UnicodeDecodeError: + continue perform_pre_scan_documents_actions(context, scan_type, documents, is_git_diff) return scan_documents(context, documents, is_git_diff=is_git_diff, scan_parameters=scan_parameters) -def scan_documents(context: click.Context, documents_to_scan: List[Document], is_git_diff: bool = False, - is_commit_range: bool = False, scan_parameters: dict = None): - cycode_client = context.obj["client"] - scan_type = context.obj["scan_type"] - severity_threshold = context.obj["severity_threshold"] - command_scan_type = context.info_name - error_message = None - all_detections_count = 0 - output_detections_count = 0 - scan_id = _get_scan_id(context) - zipped_documents = InMemoryZip() +def set_issue_detected_by_scan_results(context: click.Context, scan_results: List[LocalScanResult]) -> None: + set_issue_detected(context, any(scan_result.issue_detected for scan_result in scan_results)) - try: - logger.debug("Preparing local files") - zipped_documents = zip_documents_to_scan(scan_type, zipped_documents, documents_to_scan) - scan_result = perform_scan(context, cycode_client, zipped_documents, scan_type, scan_id, is_git_diff, - is_commit_range, - scan_parameters) +def _get_scan_documents_thread_func( + context: click.Context, is_git_diff: bool, is_commit_range: bool, scan_parameters: dict +) -> Callable[[List[Document]], Tuple[str, CliError, LocalScanResult]]: + cycode_client = context.obj['client'] + scan_type = context.obj['scan_type'] + severity_threshold = context.obj['severity_threshold'] + command_scan_type = context.info_name - all_detections_count, output_detections_count = \ - handle_scan_result(context, scan_result, command_scan_type, scan_type, severity_threshold, - documents_to_scan) - scan_completed = True - except Exception as e: - _handle_exception(context, e) - error_message = str(e) + def _scan_batch_thread_func(batch: List[Document]) -> Tuple[str, CliError, LocalScanResult]: + local_scan_result = error = error_message = None + detections_count = relevant_detections_count = zip_file_size = 0 + + scan_id = str(_get_scan_id()) scan_completed = False - zip_file_size = getsizeof(zipped_documents.in_memory_zip) - logger.debug('Finished scan process, %s', - {'all_violations_count': all_detections_count, 'relevant_violations_count': output_detections_count, - 'scan_id': str(scan_id), 'zip_file_size': zip_file_size}) - _report_scan_status(context, scan_type, str(scan_id), scan_completed, output_detections_count, - all_detections_count, len(documents_to_scan), zip_file_size, command_scan_type, error_message) + try: + logger.debug('Preparing local files, %s', {'batch_size': len(batch)}) + zipped_documents = zip_documents_to_scan(scan_type, InMemoryZip(), batch) + zip_file_size = getsizeof(zipped_documents.in_memory_zip) + + scan_result = perform_scan( + cycode_client, zipped_documents, scan_type, scan_id, is_git_diff, is_commit_range, scan_parameters + ) + local_scan_result = create_local_scan_result( + scan_result, batch, command_scan_type, scan_type, severity_threshold + ) + + scan_completed = True + except Exception as e: + error = _handle_exception(context, e, return_exception=True) + error_message = str(e) -def scan_commit_range_documents(context: click.Context, from_documents_to_scan: List[Document], - to_documents_to_scan: List[Document], scan_parameters: dict = None, - timeout: int = None): - cycode_client = context.obj["client"] - scan_type = context.obj["scan_type"] - severity_threshold = context.obj["severity_threshold"] + if local_scan_result: + detections_count = local_scan_result.detections_count + relevant_detections_count = local_scan_result.relevant_detections_count + scan_id = local_scan_result.scan_id + + logger.debug( + 'Finished scan process, %s', + { + 'all_violations_count': detections_count, + 'relevant_violations_count': relevant_detections_count, + 'scan_id': scan_id, + 'zip_file_size': zip_file_size + } + ) + _report_scan_status( + cycode_client, scan_type, scan_id, scan_completed, relevant_detections_count, + detections_count, len(batch), zip_file_size, command_scan_type, error_message + ) + + return scan_id, error, local_scan_result + + return _scan_batch_thread_func + + +def scan_documents( + context: click.Context, + documents_to_scan: List[Document], + is_git_diff: bool = False, + is_commit_range: bool = False, + scan_parameters: Optional[dict] = None, +) -> None: + progress_bar = context.obj['progress_bar'] + + scan_batch_thread_func = _get_scan_documents_thread_func(context, is_git_diff, is_commit_range, scan_parameters) + errors, local_scan_results = run_parallel_batched_scan( + scan_batch_thread_func, documents_to_scan, progress_bar=progress_bar + ) + + progress_bar.set_section_length(ProgressBarSection.GENERATE_REPORT, 1) + progress_bar.update(ProgressBarSection.GENERATE_REPORT) + progress_bar.stop() + + set_issue_detected_by_scan_results(context, local_scan_results) + print_results(context, local_scan_results) + + if not errors: + return + + if context.obj['output'] == 'json': + # TODO(MarshalX): we can't just print JSON formatted errors here + # because we should return only one root json structure per scan + # could be added later to "print_results" function if we wish to display detailed errors in UI + return + + click.secho( + 'Unfortunately, Cycode was unable to complete the full scan. ' + 'Please note that not all results may be available:', + fg='red' + ) + for scan_id, error in errors.items(): + click.echo(f'- {scan_id}: ', nl=False) + ConsolePrinter(context).print_error(error) + + +def scan_commit_range_documents( + context: click.Context, + from_documents_to_scan: List[Document], + to_documents_to_scan: List[Document], + scan_parameters: Optional[dict] = None, + timeout: Optional[int] = None +) -> None: + cycode_client = context.obj['client'] + scan_type = context.obj['scan_type'] + severity_threshold = context.obj['severity_threshold'] scan_command_type = context.info_name - error_message = None - all_detections_count = 0 - output_detections_count = 0 - scan_id = _get_scan_id(context) + progress_bar = context.obj['progress_bar'] + + local_scan_result = error_message = None + scan_completed = False + scan_id = str(_get_scan_id()) + from_commit_zipped_documents = InMemoryZip() to_commit_zipped_documents = InMemoryZip() try: - scan_result = init_default_scan_result(str(scan_id)) + progress_bar.set_section_length(ProgressBarSection.SCAN, 1) + + scan_result = init_default_scan_result(scan_id) if should_scan_documents(from_documents_to_scan, to_documents_to_scan): - logger.debug("Preparing from-commit zip") - from_commit_zipped_documents = zip_documents_to_scan(scan_type, from_commit_zipped_documents, - from_documents_to_scan) - logger.debug("Preparing to-commit zip") - to_commit_zipped_documents = zip_documents_to_scan(scan_type, to_commit_zipped_documents, - to_documents_to_scan) - scan_result = perform_commit_range_scan_async(context, cycode_client, from_commit_zipped_documents, - to_commit_zipped_documents, scan_type, scan_parameters, - timeout) - all_detections_count, output_detections_count = \ - handle_scan_result(context, scan_result, scan_command_type, scan_type, severity_threshold, - to_documents_to_scan) + logger.debug('Preparing from-commit zip') + from_commit_zipped_documents = zip_documents_to_scan( + scan_type, from_commit_zipped_documents, from_documents_to_scan + ) + + logger.debug('Preparing to-commit zip') + to_commit_zipped_documents = zip_documents_to_scan( + scan_type, to_commit_zipped_documents, to_documents_to_scan + ) + + scan_result = perform_commit_range_scan_async( + cycode_client, from_commit_zipped_documents, to_commit_zipped_documents, + scan_type, scan_parameters, timeout + ) + + progress_bar.update(ProgressBarSection.SCAN) + progress_bar.set_section_length(ProgressBarSection.GENERATE_REPORT, 1) + + local_scan_result = create_local_scan_result( + scan_result, to_documents_to_scan, scan_command_type, scan_type, severity_threshold + ) + set_issue_detected_by_scan_results(context, [local_scan_result]) + + progress_bar.update(ProgressBarSection.GENERATE_REPORT) + progress_bar.stop() + + print_results(context, [local_scan_result]) + scan_completed = True except Exception as e: _handle_exception(context, e) error_message = str(e) - scan_completed = False - zip_file_size = getsizeof(from_commit_zipped_documents.in_memory_zip) + getsizeof( - to_commit_zipped_documents.in_memory_zip) - logger.debug('Finished scan process, %s', - {'all_violations_count': all_detections_count, 'relevant_violations_count': output_detections_count, - 'scan_id': str(scan_id), 'zip_file_size': zip_file_size}) - _report_scan_status(context, scan_type, str(scan_id), scan_completed, output_detections_count, - all_detections_count, len(to_documents_to_scan), zip_file_size, scan_command_type, - error_message) + zip_file_size = getsizeof(from_commit_zipped_documents.in_memory_zip) + \ + getsizeof(to_commit_zipped_documents.in_memory_zip) + + detections_count = relevant_detections_count = 0 + if local_scan_result: + detections_count = local_scan_result.detections_count + relevant_detections_count = local_scan_result.relevant_detections_count + scan_id = local_scan_result.scan_id + + logger.debug( + 'Finished scan process, %s', + { + 'all_violations_count': detections_count, + 'relevant_violations_count': relevant_detections_count, + 'scan_id': scan_id, + 'zip_file_size': zip_file_size + } + ) + _report_scan_status( + cycode_client, scan_type, local_scan_result.scan_id, scan_completed, + local_scan_result.relevant_detections_count, local_scan_result.detections_count, len(to_documents_to_scan), + zip_file_size, scan_command_type, error_message + ) def should_scan_documents(from_documents_to_scan: List[Document], to_documents_to_scan: List[Document]) -> bool: return len(from_documents_to_scan) > 0 or len(to_documents_to_scan) > 0 -def handle_scan_result(context, scan_result, command_scan_type, scan_type, severity_threshold, to_documents_to_scan): - document_detections_list = enrich_scan_result(scan_result, to_documents_to_scan) - relevant_document_detections_list = exclude_irrelevant_scan_results(document_detections_list, scan_type, - command_scan_type, severity_threshold) - context.obj['report_url'] = scan_result.report_url - print_results(context, relevant_document_detections_list) - context.obj['issue_detected'] = len(relevant_document_detections_list) > 0 - all_detections_count = sum( - [len(document_detections.detections) for document_detections in document_detections_list]) - output_detections_count = sum( - [len(document_detections.detections) for document_detections in relevant_document_detections_list]) - return all_detections_count, output_detections_count - - -def perform_pre_scan_documents_actions(context: click.Context, scan_type: str, documents_to_scan: List[Document], - is_git_diff: bool = False): - if scan_type == SCA_SCAN_TYPE: - logger.debug( - f"Perform pre scan document actions") +def create_local_scan_result( + scan_result: ZippedFileScanResult, + documents_to_scan: List[Document], + command_scan_type: str, + scan_type: str, + severity_threshold: str, +) -> LocalScanResult: + document_detections = get_document_detections(scan_result, documents_to_scan) + relevant_document_detections_list = exclude_irrelevant_document_detections( + document_detections, scan_type, command_scan_type, severity_threshold + ) + + detections_count = sum([len(document_detection.detections) for document_detection in document_detections]) + relevant_detections_count = sum( + [len(document_detections.detections) for document_detections in relevant_document_detections_list] + ) + + return LocalScanResult( + scan_id=scan_result.scan_id, + report_url=scan_result.report_url, + document_detections=relevant_document_detections_list, + issue_detected=len(relevant_document_detections_list) > 0, + detections_count=detections_count, + relevant_detections_count=relevant_detections_count + ) + + +def perform_pre_scan_documents_actions( + context: click.Context, scan_type: str, documents_to_scan: List[Document], is_git_diff: bool = False +) -> None: + if scan_type == consts.SCA_SCAN_TYPE: + logger.debug(f'Perform pre scan document actions') sca_code_scanner.add_dependencies_tree_document(context, documents_to_scan, is_git_diff) -def zip_documents_to_scan(scan_type: str, zip: InMemoryZip, documents: List[Document]): +def zip_documents_to_scan(scan_type: str, zip_file: InMemoryZip, documents: List[Document]) -> InMemoryZip: start_zip_creation_time = time.time() for index, document in enumerate(documents): - zip_file_size = getsizeof(zip.in_memory_zip) + zip_file_size = getsizeof(zip_file.in_memory_zip) validate_zip_file_size(scan_type, zip_file_size) logger.debug('adding file to zip, %s', {'index': index, 'filename': document.path, 'unique_id': document.unique_id}) - zip.append(document.path, document.unique_id, document.content) - zip.close() + zip_file.append(document.path, document.unique_id, document.content) + zip_file.close() end_zip_creation_time = time.time() zip_creation_time = int(end_zip_creation_time - start_zip_creation_time) logger.debug('finished to create zip file, %s', {'zip_creation_time': zip_creation_time}) - return zip + return zip_file -def validate_zip_file_size(scan_type, zip_file_size): - if scan_type == SCA_SCAN_TYPE: - if zip_file_size > SCA_ZIP_MAX_SIZE_LIMIT_IN_BYTES: - raise ZipTooLargeError(SCA_ZIP_MAX_SIZE_LIMIT_IN_BYTES) +def validate_zip_file_size(scan_type: str, zip_file_size: int) -> None: + if scan_type == consts.SCA_SCAN_TYPE: + if zip_file_size > consts.SCA_ZIP_MAX_SIZE_LIMIT_IN_BYTES: + raise custom_exceptions.ZipTooLargeError(consts.SCA_ZIP_MAX_SIZE_LIMIT_IN_BYTES) else: - if zip_file_size > ZIP_MAX_SIZE_LIMIT_IN_BYTES: - raise ZipTooLargeError(ZIP_MAX_SIZE_LIMIT_IN_BYTES) - - -def perform_scan(context, cycode_client, zipped_documents: InMemoryZip, scan_type: str, scan_id: UUID, - is_git_diff: bool, - is_commit_range: bool, scan_parameters: dict): - if scan_type == SCA_SCAN_TYPE or scan_type == SAST_SCAN_TYPE: - return perform_scan_async(context, cycode_client, zipped_documents, scan_type, scan_parameters) - - scan_result = cycode_client.commit_range_zipped_file_scan(scan_type, zipped_documents, scan_id) \ - if is_commit_range else cycode_client.zipped_file_scan(scan_type, zipped_documents, scan_id, - scan_parameters, is_git_diff) - - return scan_result - - -def perform_scan_async(context: click.Context, cycode_client, zipped_documents: InMemoryZip, scan_type: str, - scan_parameters: dict) -> ZippedFileScanResult: + if zip_file_size > consts.ZIP_MAX_SIZE_LIMIT_IN_BYTES: + raise custom_exceptions.ZipTooLargeError(consts.ZIP_MAX_SIZE_LIMIT_IN_BYTES) + + +def perform_scan( + cycode_client: 'ScanClient', + zipped_documents: InMemoryZip, + scan_type: str, + scan_id: str, + is_git_diff: bool, + is_commit_range: bool, + scan_parameters: dict +) -> ZippedFileScanResult: + if scan_type in (consts.SCA_SCAN_TYPE, consts.SAST_SCAN_TYPE): + return perform_scan_async(cycode_client, zipped_documents, scan_type, scan_parameters) + + if is_commit_range: + return cycode_client.commit_range_zipped_file_scan(scan_type, zipped_documents, scan_id) + + return cycode_client.zipped_file_scan(scan_type, zipped_documents, scan_id, scan_parameters, is_git_diff) + + +def perform_scan_async( + cycode_client: 'ScanClient', + zipped_documents: InMemoryZip, + scan_type: str, + scan_parameters: dict +) -> ZippedFileScanResult: scan_async_result = cycode_client.zipped_file_scan_async(zipped_documents, scan_type, scan_parameters) logger.debug("scan request has been triggered successfully, scan id: %s", scan_async_result.scan_id) - return poll_scan_results(context, cycode_client, scan_async_result.scan_id) + return poll_scan_results(cycode_client, scan_async_result.scan_id) + +def perform_commit_range_scan_async( + cycode_client: 'ScanClient', + from_commit_zipped_documents: InMemoryZip, + to_commit_zipped_documents: InMemoryZip, + scan_type: str, + scan_parameters: dict, + timeout: int = None +) -> ZippedFileScanResult: + scan_async_result = cycode_client.multiple_zipped_file_scan_async( + from_commit_zipped_documents, to_commit_zipped_documents, scan_type, scan_parameters + ) -def perform_commit_range_scan_async(context: click.Context, cycode_client, from_commit_zipped_documents: InMemoryZip, - to_commit_zipped_documents: InMemoryZip, scan_type: str, - scan_parameters: dict, timeout: int = None) -> ZippedFileScanResult: - scan_async_result = \ - cycode_client.multiple_zipped_file_scan_async(from_commit_zipped_documents, to_commit_zipped_documents, - scan_type, scan_parameters) logger.debug("scan request has been triggered successfully, scan id: %s", scan_async_result.scan_id) - return poll_scan_results(context, cycode_client, scan_async_result.scan_id, timeout) + return poll_scan_results(cycode_client, scan_async_result.scan_id, timeout) -def poll_scan_results(context: click.Context, cycode_client, scan_id: str, polling_timeout: int = None): +def poll_scan_results( + cycode_client: 'ScanClient', scan_id: str, polling_timeout: Optional[int] = None +) -> ZippedFileScanResult: if polling_timeout is None: polling_timeout = configuration_manager.get_scan_polling_timeout_in_seconds() last_scan_update_at = None end_polling_time = time.time() + polling_timeout - spinner = Halo(spinner='dots') - spinner.start("Scan in progress") + while time.time() < end_polling_time: scan_details = cycode_client.get_scan_details(scan_id) + if scan_details.scan_update_at is not None and scan_details.scan_update_at != last_scan_update_at: - click.echo() last_scan_update_at = scan_details.scan_update_at - print_scan_details(scan_details) - if scan_details.scan_status == SCAN_STATUS_COMPLETED: - spinner.succeed() + print_debug_scan_details(scan_details) + + if scan_details.scan_status == consts.SCAN_STATUS_COMPLETED: return _get_scan_result(cycode_client, scan_id, scan_details) - if scan_details.scan_status == SCAN_STATUS_ERROR: - spinner.fail() - raise ScanAsyncError(f'error occurred while trying to scan zip file. {scan_details.message}') - time.sleep(SCAN_POLLING_WAIT_INTERVAL_IN_SECONDS) + elif scan_details.scan_status == consts.SCAN_STATUS_ERROR: + raise custom_exceptions.ScanAsyncError( + f'Error occurred while trying to scan zip file. {scan_details.message}' + ) + + time.sleep(consts.SCAN_POLLING_WAIT_INTERVAL_IN_SECONDS) - spinner.stop_and_persist(symbol="⏰".encode('utf-8'), text='Timeout') - raise ScanAsyncError(f'Failed to complete scan after {polling_timeout} seconds') + raise custom_exceptions.ScanAsyncError(f'Failed to complete scan after {polling_timeout} seconds') -def print_scan_details(scan_details_response: ScanDetailsResponse): - logger.info(f"Scan update: (scan_id: {scan_details_response.id})") - logger.info(f"Scan status: {scan_details_response.scan_status}") - if scan_details_response.message is not None: - logger.info(f"Scan message: {scan_details_response.message}") +def print_debug_scan_details(scan_details_response: 'ScanDetailsResponse') -> None: + logger.debug(f'Scan update: (scan_id: {scan_details_response.id})') + logger.debug(f'Scan status: {scan_details_response.scan_status}') + if scan_details_response.message: + logger.debug(f'Scan message: {scan_details_response.message}') -def print_results(context: click.Context, document_detections_list: List[DocumentDetections]) -> None: + +def print_results(context: click.Context, local_scan_results: List[LocalScanResult]) -> None: printer = ConsolePrinter(context) - printer.print_scan_results(document_detections_list) + printer.print_scan_results(local_scan_results) -def enrich_scan_result( +def get_document_detections( scan_result: ZippedFileScanResult, documents_to_scan: List[Document] ) -> List[DocumentDetections]: - logger.debug('enriching scan result') - document_detections_list = [] + logger.debug('Get document detections') + + document_detections = [] for detections_per_file in scan_result.detections_per_file: file_name = get_path_by_os(detections_per_file.file_name) commit_id = detections_per_file.commit_id - logger.debug("going to find document of violated file, %s", {'file_name': file_name, 'commit_id': commit_id}) + + logger.debug('Going to find document of violated file, %s', {'file_name': file_name, 'commit_id': commit_id}) + document = _get_document_by_file_name(documents_to_scan, file_name, commit_id) - document_detections_list.append( - DocumentDetections(document=document, detections=detections_per_file.detections)) + document_detections.append( + DocumentDetections(document=document, detections=detections_per_file.detections) + ) - return document_detections_list + return document_detections -def exclude_irrelevant_scan_results(document_detections_list: List[DocumentDetections], scan_type: str, - command_scan_type: str, severity_threshold: str) -> List[DocumentDetections]: +def exclude_irrelevant_document_detections( + document_detections_list: List[DocumentDetections], + scan_type: str, + command_scan_type: str, + severity_threshold: str +) -> List[DocumentDetections]: relevant_document_detections_list = [] for document_detections in document_detections_list: - relevant_detections = exclude_irrelevant_detections(scan_type, command_scan_type, severity_threshold, - document_detections.detections) + relevant_detections = exclude_irrelevant_detections( + document_detections.detections, scan_type, command_scan_type, severity_threshold + ) if relevant_detections: - relevant_document_detections_list.append(DocumentDetections(document=document_detections.document, - detections=relevant_detections)) + relevant_document_detections_list.append( + DocumentDetections(document=document_detections.document, detections=relevant_detections) + ) return relevant_document_detections_list @@ -492,13 +694,14 @@ def parse_pre_receive_input() -> str: :return: first branch update details (input's first line) """ + # FIXME(MarshalX): this blocks main thread forever if called outside of pre-receive hook pre_receive_input = sys.stdin.read().strip() if not pre_receive_input: raise ValueError( "Pre receive input was not found. Make sure that you are using this command only in pre-receive hook") # each line represents a branch update request, handle the first one only - # TODO support case of multiple update branch requests + # TODO(MichalBor): support case of multiple update branch requests branch_update_details = pre_receive_input.splitlines()[0] return branch_update_details @@ -507,7 +710,7 @@ def calculate_pre_receive_commit_range(branch_update_details: str) -> Optional[s end_commit = get_end_commit_from_branch_update_details(branch_update_details) # branch is deleted, no need to perform scan - if end_commit == EMPTY_COMMIT_SHA: + if end_commit == consts.EMPTY_COMMIT_SHA: return start_commit = get_oldest_unupdated_commit_for_branch(end_commit) @@ -543,7 +746,7 @@ def get_diff_file_content(file): return file.diff.decode('utf-8', errors='replace') -def should_process_git_object(obj, depth): +def should_process_git_object(obj, _: int) -> bool: return obj.type == 'blob' and obj.size > 0 @@ -560,12 +763,12 @@ def get_default_scan_parameters(context: click.Context) -> dict: } -def get_scan_parameters(context: click.Context) -> dict: - path = context.obj["path"] +def get_scan_parameters(context: click.Context, path: str) -> dict: scan_parameters = get_default_scan_parameters(context) remote_url = try_get_git_remote_url(path) if remote_url: - context.obj["remote_url"] = remote_url + # TODO(MarshalX): remove hardcode + context.obj['remote_url'] = remote_url scan_parameters.update(remote_url) return scan_parameters @@ -581,44 +784,68 @@ def try_get_git_remote_url(path: str) -> Optional[dict]: return None -def exclude_irrelevant_documents_to_scan(context: click.Context, documents_to_scan: List[Document]) -> \ - List[Document]: +def exclude_irrelevant_documents_to_scan( + context: click.Context, documents_to_scan: List[Document] +) -> List[Document]: + logger.debug('Excluding irrelevant documents to scan') + scan_type = context.obj['scan_type'] - logger.debug("excluding irrelevant documents to scan") - return [document for document in documents_to_scan if - _is_relevant_document_to_scan(scan_type, document.path, document.content)] + + relevant_documents = [] + for document in documents_to_scan: + if _is_relevant_document_to_scan(scan_type, document.path, document.content): + relevant_documents.append(document) + + return relevant_documents def exclude_irrelevant_files(context: click.Context, filenames: List[str]) -> List[str]: scan_type = context.obj['scan_type'] - return [filename for filename in filenames if _is_relevant_file_to_scan(scan_type, filename)] + + relevant_files = [] + for filename in filenames: + if _is_relevant_file_to_scan(scan_type, filename): + relevant_files.append(filename) + + return relevant_files -def exclude_irrelevant_detections(scan_type: str, command_scan_type: str, severity_threshold: str, detections) -> List: - relevant_detections = exclude_detections_by_exclusions_configuration(scan_type, detections) - relevant_detections = exclude_detections_by_scan_type(scan_type, command_scan_type, relevant_detections) - relevant_detections = exclude_detections_by_severity(scan_type, severity_threshold, relevant_detections) +def exclude_irrelevant_detections( + detections: List[Detection], scan_type: str, command_scan_type: str, severity_threshold: str +) -> List[Detection]: + relevant_detections = _exclude_detections_by_exclusions_configuration(detections, scan_type) + relevant_detections = _exclude_detections_by_scan_type(relevant_detections, scan_type, command_scan_type) + relevant_detections = _exclude_detections_by_severity(relevant_detections, scan_type, severity_threshold) return relevant_detections -def exclude_detections_by_severity(scan_type: str, severity_threshold: str, detections) -> List: - if scan_type != SCA_SCAN_TYPE or severity_threshold is None: +def _exclude_detections_by_severity( + detections: List[Detection], scan_type: str, severity_threshold: str +) -> List[Detection]: + if scan_type != consts.SCA_SCAN_TYPE or severity_threshold is None: return detections - return [detection for detection in detections if - _does_severity_match_severity_threshold(detection.detection_details.get('advisory_severity'), - severity_threshold)] + relevant_detections = [] + for detection in detections: + severity = detection.detection_details.get('advisory_severity') + if _does_severity_match_severity_threshold(severity, severity_threshold): + relevant_detections.append(detection) + return relevant_detections -def exclude_detections_by_scan_type(scan_type: str, command_scan_type: str, detections) -> List: - if command_scan_type == PRE_COMMIT_COMMAND_SCAN_TYPE: - return exclude_detections_in_deleted_lines(detections) - if command_scan_type in COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES \ - and scan_type == SECRET_SCAN_TYPE \ - and configuration_manager.get_should_exclude_detections_in_deleted_lines(command_scan_type): +def _exclude_detections_by_scan_type( + detections: List[Detection], scan_type: str, command_scan_type: str +) -> List[Detection]: + if command_scan_type == consts.PRE_COMMIT_COMMAND_SCAN_TYPE: return exclude_detections_in_deleted_lines(detections) + + exclude_in_deleted_lines = configuration_manager.get_should_exclude_detections_in_deleted_lines(command_scan_type) + if command_scan_type in consts.COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES: + if scan_type == consts.SECRET_SCAN_TYPE and exclude_in_deleted_lines: + return exclude_detections_in_deleted_lines(detections) + return detections @@ -626,21 +853,25 @@ def exclude_detections_in_deleted_lines(detections) -> List: return [detection for detection in detections if detection.detection_details.get('line_type') != 'Removed'] -def exclude_detections_by_exclusions_configuration(scan_type: str, detections) -> List: +def _exclude_detections_by_exclusions_configuration(detections: List[Detection], scan_type: str) -> List[Detection]: exclusions = configuration_manager.get_exclusions_by_scan_type(scan_type) return [detection for detection in detections if not _should_exclude_detection(detection, exclusions)] -def get_pre_commit_modified_documents(): - repo = Repo(os.getcwd()) - diff_files = repo.index.diff(GIT_HEAD_COMMIT_REV, create_patch=True, R=True) +def get_pre_commit_modified_documents(progress_bar: 'BaseProgressBar') -> Tuple[List[Document], List[Document]]: git_head_documents = [] pre_committed_documents = [] + + repo = Repo(os.getcwd()) + diff_files = repo.index.diff(consts.GIT_HEAD_COMMIT_REV, create_patch=True, R=True) + progress_bar.set_section_length(ProgressBarSection.PREPARE_LOCAL_FILES, len(diff_files)) for file in diff_files: + progress_bar.update(ProgressBarSection.PREPARE_LOCAL_FILES) + diff_file_path = get_diff_file_path(file) file_path = get_path_by_os(diff_file_path) - file_content = sca_code_scanner.get_file_content_from_commit(repo, GIT_HEAD_COMMIT_REV, diff_file_path) + file_content = sca_code_scanner.get_file_content_from_commit(repo, consts.GIT_HEAD_COMMIT_REV, diff_file_path) if file_content is not None: git_head_documents.append(Document(file_path, file_content)) @@ -651,14 +882,22 @@ def get_pre_commit_modified_documents(): return git_head_documents, pre_committed_documents -def get_commit_range_modified_documents(path: str, from_commit_rev: str, to_commit_rev: str) -> ( - List[Document], List[Document]): +def get_commit_range_modified_documents( + progress_bar: 'BaseProgressBar', path: str, from_commit_rev: str, to_commit_rev: str +) -> Tuple[List[Document], List[Document]]: from_commit_documents = [] to_commit_documents = [] + repo = Repo(path) diff = repo.commit(from_commit_rev).diff(to_commit_rev) - modified_files_diff = [change for change in diff if change.change_type != COMMIT_DIFF_DELETED_FILE_CHANGE_TYPE] + + modified_files_diff = [ + change for change in diff if change.change_type != consts.COMMIT_DIFF_DELETED_FILE_CHANGE_TYPE + ] + progress_bar.set_section_length(ProgressBarSection.PREPARE_LOCAL_FILES, len(modified_files_diff)) for blob in modified_files_diff: + progress_bar.update(ProgressBarSection.PREPARE_LOCAL_FILES) + diff_file_path = get_diff_file_path(blob) file_path = get_path_by_os(diff_file_path) @@ -673,20 +912,20 @@ def get_commit_range_modified_documents(path: str, from_commit_rev: str, to_comm return from_commit_documents, to_commit_documents -def _should_exclude_detection(detection, exclusions: Dict) -> bool: - exclusions_by_value = exclusions.get(EXCLUSIONS_BY_VALUE_SECTION_NAME, []) +def _should_exclude_detection(detection: Detection, exclusions: Dict) -> bool: + exclusions_by_value = exclusions.get(consts.EXCLUSIONS_BY_VALUE_SECTION_NAME, []) if _is_detection_sha_configured_in_exclusions(detection, exclusions_by_value): logger.debug('Going to ignore violations because is in the values to ignore list, %s', {'sha': detection.detection_details.get('sha512', '')}) return True - exclusions_by_sha = exclusions.get(EXCLUSIONS_BY_SHA_SECTION_NAME, []) + exclusions_by_sha = exclusions.get(consts.EXCLUSIONS_BY_SHA_SECTION_NAME, []) if _is_detection_sha_configured_in_exclusions(detection, exclusions_by_sha): logger.debug('Going to ignore violations because is in the shas to ignore list, %s', {'sha': detection.detection_details.get('sha512', '')}) return True - exclusions_by_rule = exclusions.get(EXCLUSIONS_BY_RULE_SECTION_NAME, []) + exclusions_by_rule = exclusions.get(consts.EXCLUSIONS_BY_RULE_SECTION_NAME, []) if exclusions_by_rule: detection_rule = detection.detection_rule_id if detection_rule in exclusions_by_rule: @@ -694,7 +933,7 @@ def _should_exclude_detection(detection, exclusions: Dict) -> bool: {'detection_rule': detection_rule}) return True - exclusions_by_package = exclusions.get(EXCLUSIONS_BY_PACKAGE_SECTION_NAME, []) + exclusions_by_package = exclusions.get(consts.EXCLUSIONS_BY_PACKAGE_SECTION_NAME, []) if exclusions_by_package: package = _get_package_name(detection) if package in exclusions_by_package: @@ -711,15 +950,15 @@ def _is_detection_sha_configured_in_exclusions(detection, exclusions: List[str]) def _is_path_configured_in_exclusions(scan_type: str, file_path: str) -> bool: - exclusions_by_path = configuration_manager.get_exclusions_by_scan_type(scan_type).get( - EXCLUSIONS_BY_PATH_SECTION_NAME, []) + exclusions_by_path = \ + configuration_manager.get_exclusions_by_scan_type(scan_type).get(consts.EXCLUSIONS_BY_PATH_SECTION_NAME, []) for exclusion_path in exclusions_by_path: if is_sub_path(exclusion_path, file_path): return True return False -def _get_package_name(detection) -> str: +def _get_package_name(detection: Detection) -> str: package_name = detection.detection_details.get('vulnerable_component', '') package_version = detection.detection_details.get('vulnerable_component_version', '') @@ -731,10 +970,13 @@ def _get_package_name(detection) -> str: def _is_file_relevant_for_sca_scan(filename: str) -> bool: - if any([sca_excluded_path in filename for sca_excluded_path in SCA_EXCLUDED_PATHS]): - logger.debug("file is irrelevant because it is from node_modules's inner path, %s", - {'filename': filename}) + if any([sca_excluded_path in filename for sca_excluded_path in consts.SCA_EXCLUDED_PATHS]): + logger.debug( + "file is irrelevant because it is from node_modules's inner path, %s", + {'filename': filename} + ) return False + return True @@ -759,12 +1001,12 @@ def _is_relevant_file_to_scan(scan_type: str, filename: str) -> bool: {'filename': filename}) return False - if scan_type != SCA_SCAN_TYPE and _does_file_exceed_max_size_limit(filename): + if scan_type != consts.SCA_SCAN_TYPE and _does_file_exceed_max_size_limit(filename): logger.debug("file is irrelevant because its exceeded max size limit, %s", {'filename': filename}) return False - if scan_type == SCA_SCAN_TYPE and not _is_file_relevant_for_sca_scan(filename): + if scan_type == consts.SCA_SCAN_TYPE and not _is_file_relevant_for_sca_scan(filename): return False return True @@ -791,7 +1033,7 @@ def _is_relevant_document_to_scan(scan_type: str, filename: str, content: str) - {'filename': filename}) return False - if scan_type != SCA_SCAN_TYPE and _does_document_exceed_max_size_limit(content): + if scan_type != consts.SCA_SCAN_TYPE and _does_document_exceed_max_size_limit(content): logger.debug("document is irrelevant because its exceeded max size limit, %s", {'filename': filename}) return False @@ -799,29 +1041,35 @@ def _is_relevant_document_to_scan(scan_type: str, filename: str, content: str) - def _is_file_extension_supported(scan_type: str, filename: str) -> bool: - if scan_type == INFRA_CONFIGURATION_SCAN_TYPE: - return any(filename.lower().endswith(supported_file_extension) for supported_file_extension in - INFRA_CONFIGURATION_SCAN_SUPPORTED_FILES) - if scan_type == SCA_SCAN_TYPE: - return any(filename.lower().endswith(supported_file) for supported_file in - SCA_CONFIGURATION_SCAN_SUPPORTED_FILES) - return all(not filename.lower().endswith(file_extension_to_ignore) for file_extension_to_ignore in - SECRET_SCAN_FILE_EXTENSIONS_TO_IGNORE) + filename = filename.lower() + if scan_type == consts.INFRA_CONFIGURATION_SCAN_TYPE: + return any(filename.endswith(supported_file_extension) + for supported_file_extension in consts.INFRA_CONFIGURATION_SCAN_SUPPORTED_FILES) + elif scan_type == consts.SCA_SCAN_TYPE: + return any(filename.endswith(supported_file) + for supported_file in consts.SCA_CONFIGURATION_SCAN_SUPPORTED_FILES) -def _does_file_exceed_max_size_limit(filename: str) -> bool: - return FILE_MAX_SIZE_LIMIT_IN_BYTES < get_file_size(filename) + return all(not filename.endswith(file_extension_to_ignore) + for file_extension_to_ignore in consts.SECRET_SCAN_FILE_EXTENSIONS_TO_IGNORE) -def _get_document_by_file_name(documents: List[Document], file_name: str, unique_id: Optional[str] = None) \ - -> Optional[Document]: - return next( - (document for document in documents if _normalize_file_path(document.path) == _normalize_file_path(file_name) - and document.unique_id == unique_id), None) +def _does_file_exceed_max_size_limit(filename: str) -> bool: + return consts.FILE_MAX_SIZE_LIMIT_IN_BYTES < get_file_size(filename) def _does_document_exceed_max_size_limit(content: str) -> bool: - return FILE_MAX_SIZE_LIMIT_IN_BYTES < get_content_size(content) + return consts.FILE_MAX_SIZE_LIMIT_IN_BYTES < get_content_size(content) + + +def _get_document_by_file_name( + documents: List[Document], file_name: str, unique_id: Optional[str] = None +) -> Optional[Document]: + for document in documents: + if _normalize_file_path(document.path) == _normalize_file_path(file_name) and document.unique_id == unique_id: + return document + + return None def _is_subpath_of_cycode_configuration_folder(filename: str) -> bool: @@ -830,32 +1078,32 @@ def _is_subpath_of_cycode_configuration_folder(filename: str) -> bool: or filename.endswith(ConfigFileManager.get_config_file_route()) -def _handle_exception(context: click.Context, e: Exception): +def _handle_exception(context: click.Context, e: Exception, *, return_exception: bool = False) -> Optional[CliError]: context.obj['did_fail'] = True if context.obj['verbose']: - click.secho(f'Error: {traceback.format_exc()}', fg='red', nl=False) + click.secho(f'Error: {traceback.format_exc()}', fg='red') errors: CliErrors = { - NetworkError: CliError( + custom_exceptions.NetworkError: CliError( soft_fail=True, code='cycode_error', message='Cycode was unable to complete this scan. ' 'Please try again by executing the `cycode scan` command' ), - ScanAsyncError: CliError( + custom_exceptions.ScanAsyncError: CliError( soft_fail=True, code='scan_error', message='Cycode was unable to complete this scan. ' 'Please try again by executing the `cycode scan` command' ), - HttpUnauthorizedError: CliError( + custom_exceptions.HttpUnauthorizedError: CliError( soft_fail=True, code='auth_error', message='Unable to authenticate to Cycode, your token is either invalid or has expired. ' 'Please re-generate your token and reconfigure it by running the `cycode configure` command' ), - ZipTooLargeError: CliError( + custom_exceptions.ZipTooLargeError: CliError( soft_fail=True, code='zip_too_large_error', message='The path you attempted to scan exceeds the current maximum scanning size cap (10MB). ' @@ -876,7 +1124,14 @@ def _handle_exception(context: click.Context, e: Exception): if error.soft_fail is True: context.obj['soft_fail'] = True - return ConsolePrinter(context).print_error(error) + if return_exception: + return error + + ConsolePrinter(context).print_error(error) + return + + if return_exception: + return CliError(code='unknown_error', message=str(e)) if isinstance(e, click.ClickException): raise e @@ -884,11 +1139,10 @@ def _handle_exception(context: click.Context, e: Exception): raise click.ClickException(str(e)) -def _report_scan_status(context: click.Context, scan_type: str, scan_id: str, scan_completed: bool, +def _report_scan_status(cycode_client: 'ScanClient', scan_type: str, scan_id: str, scan_completed: bool, output_detections_count: int, all_detections_count: int, files_to_scan_count: int, - zip_size: int, command_scan_type: str, error_message: Optional[str]): + zip_size: int, command_scan_type: str, error_message: Optional[str]) -> None: try: - cycode_client = context.obj["client"] end_scan_time = time.time() scan_status = { 'zip_size': zip_size, @@ -906,13 +1160,10 @@ def _report_scan_status(context: click.Context, scan_type: str, scan_id: str, sc cycode_client.report_scan_status(scan_type, scan_id, scan_status) except Exception as e: logger.debug('Failed to report scan status, %s', {'exception_message': str(e)}) - pass -def _get_scan_id(context: click.Context): - scan_id = uuid4() - context.obj['scan_id'] = scan_id - return scan_id +def _get_scan_id() -> UUID: + return uuid4() def _does_severity_match_severity_threshold(severity: str, severity_threshold: str) -> bool: @@ -923,51 +1174,69 @@ def _does_severity_match_severity_threshold(severity: str, severity_threshold: s return detection_severity_value >= Severity.try_get_value(severity_threshold) -def _get_scan_result(cycode_client, scan_id: str, scan_details: ScanDetailsResponse) -> ZippedFileScanResult: - scan_result = init_default_scan_result(scan_id, scan_details.metadata) +def _get_scan_result( + cycode_client: 'ScanClient', scan_id: str, scan_details: 'ScanDetailsResponse' +) -> ZippedFileScanResult: if not scan_details.detections_count: - return scan_result + return init_default_scan_result(scan_id, scan_details.metadata) wait_for_detections_creation(cycode_client, scan_id, scan_details.detections_count) + scan_detections = cycode_client.get_scan_detections(scan_id) - scan_result.detections_per_file = _map_detections_per_file(scan_detections) - scan_result.did_detect = True - return scan_result + return ZippedFileScanResult( + did_detect=True, + detections_per_file=_map_detections_per_file(scan_detections), + scan_id=scan_id, + report_url=_try_get_report_url(scan_details.metadata) + ) -def init_default_scan_result(scan_id: str, scan_metadata: str = None) -> ZippedFileScanResult: - return ZippedFileScanResult(did_detect=False, detections_per_file=[], - scan_id=scan_id, - report_url=_try_get_report_url(scan_metadata)) +def init_default_scan_result(scan_id: str, scan_metadata: Optional[str] = None) -> ZippedFileScanResult: + return ZippedFileScanResult( + did_detect=False, + detections_per_file=[], + scan_id=scan_id, + report_url=_try_get_report_url(scan_metadata) + ) -def _try_get_report_url(metadata: str) -> Optional[str]: - if metadata is None: +def _try_get_report_url(metadata_json: Optional[str]) -> Optional[str]: + if metadata_json is None: return None + try: - metadata = json.loads(metadata) - return metadata.get('report_url') - except ValueError: + metadata_json = json.loads(metadata_json) + return metadata_json.get('report_url') + except json.JSONDecodeError: return None -def wait_for_detections_creation(cycode_client, scan_id: str, expected_detections_count: int): - logger.debug("waiting for detections to be created") +def wait_for_detections_creation(cycode_client: 'ScanClient', scan_id: str, expected_detections_count: int) -> None: + logger.debug('Waiting for detections to be created') + scan_persisted_detections_count = 0 - end_polling_time = time.time() + DETECTIONS_COUNT_VERIFICATION_TIMEOUT_IN_SECONDS - spinner = Halo(spinner='dots') - spinner.start("Please wait until printing scan result...") + polling_timeout = consts.DETECTIONS_COUNT_VERIFICATION_TIMEOUT_IN_SECONDS + end_polling_time = time.time() + polling_timeout + while time.time() < end_polling_time: scan_persisted_detections_count = cycode_client.get_scan_detections_count(scan_id) + logger.debug( + f'Excepted {expected_detections_count} detections, got {scan_persisted_detections_count} detections ' + f'({expected_detections_count - scan_persisted_detections_count} more; ' + f'{round(end_polling_time - time.time())} seconds left)' + ) if scan_persisted_detections_count == expected_detections_count: - spinner.succeed() return - time.sleep(DETECTIONS_COUNT_VERIFICATION_WAIT_INTERVAL_IN_SECONDS) - spinner.stop_and_persist(symbol="⏰".encode('utf-8'), text='Timeout') - logger.debug("%i detections has been created", scan_persisted_detections_count) + + time.sleep(consts.DETECTIONS_COUNT_VERIFICATION_WAIT_INTERVAL_IN_SECONDS) + + logger.debug(f'{scan_persisted_detections_count} detections has been created') + raise custom_exceptions.ScanAsyncError( + f'Failed to wait for detections to be created after {polling_timeout} seconds' + ) -def _map_detections_per_file(detections) -> List[DetectionsPerFile]: +def _map_detections_per_file(detections: List[dict]) -> List[DetectionsPerFile]: detections_per_files = {} for detection in detections: try: @@ -988,24 +1257,24 @@ def _map_detections_per_file(detections) -> List[DetectionsPerFile]: for file_name, file_detections in detections_per_files.items()] -def _get_file_name_from_detection(detection): - if detection['category'] == "SAST": +def _get_file_name_from_detection(detection: dict) -> str: + if detection['category'] == 'SAST': return detection['detection_details']['file_path'] return detection['detection_details']['file_name'] -def _does_reach_to_max_commits_to_scan_limit(commit_ids: List, max_commits_count: Optional[int]) -> bool: - return max_commits_count is not None and len(commit_ids) >= max_commits_count - +def _does_reach_to_max_commits_to_scan_limit(commit_ids: List[str], max_commits_count: Optional[int]) -> bool: + if max_commits_count is None: + return False -def _should_update_progress(scanned_commits_count: int) -> bool: - return scanned_commits_count and scanned_commits_count % PROGRESS_UPDATE_COMMITS_INTERVAL == 0 + return len(commit_ids) >= max_commits_count -def parse_commit_range(commit_range: str, path: str) -> (str, str): +def parse_commit_range(commit_range: str, path: str) -> Tuple[str, str]: from_commit_rev = None to_commit_rev = None + for commit in Repo(path).iter_commits(rev=commit_range): if not to_commit_rev: to_commit_rev = commit.hexsha @@ -1014,33 +1283,35 @@ def parse_commit_range(commit_range: str, path: str) -> (str, str): return from_commit_rev, to_commit_rev -def _normalize_file_path(path: str): - if path.startswith("/"): +def _normalize_file_path(path: str) -> str: + if path.startswith('/'): return path[1:] - if path.startswith("./"): + if path.startswith('./'): return path[2:] return path -def perform_post_pre_receive_scan_actions(context: click.Context): +def perform_post_pre_receive_scan_actions(context: click.Context) -> None: if scan_utils.is_scan_failed(context): - click.echo(PRE_RECEIVE_REMEDIATION_MESSAGE) + click.echo(consts.PRE_RECEIVE_REMEDIATION_MESSAGE) -def enable_verbose_mode(context: click.Context): - context.obj["verbose"] = True +def enable_verbose_mode(context: click.Context) -> None: + context.obj['verbose'] = True + # TODO(MarshalX): rework setting the log level for loggers logger.setLevel(logging.DEBUG) + progress_bar_logger.setLevel(logging.DEBUG) def is_verbose_mode_requested_in_pre_receive_scan() -> bool: - return does_git_push_option_have_value(VERBOSE_SCAN_FLAG) + return does_git_push_option_have_value(consts.VERBOSE_SCAN_FLAG) def should_skip_pre_receive_scan() -> bool: - return does_git_push_option_have_value(SKIP_SCAN_FLAG) + return does_git_push_option_have_value(consts.SKIP_SCAN_FLAG) def does_git_push_option_have_value(value: str) -> bool: - option_count_env_value = os.getenv(GIT_PUSH_OPTION_COUNT_ENV_VAR_NAME, '') + option_count_env_value = os.getenv(consts.GIT_PUSH_OPTION_COUNT_ENV_VAR_NAME, '') option_count = int(option_count_env_value) if option_count_env_value.isdigit() else 0 - return any(os.getenv(f'{GIT_PUSH_OPTION_ENV_VAR_PREFIX}{i}') == value for i in range(option_count)) + return any(os.getenv(f'{consts.GIT_PUSH_OPTION_ENV_VAR_PREFIX}{i}') == value for i in range(option_count)) diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index f37bbdef..1959d4ae 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -84,6 +84,13 @@ # 200MB in bytes (in binary) SCA_ZIP_MAX_SIZE_LIMIT_IN_BYTES = 209715200 +# scan in batches +SCAN_BATCH_MAX_SIZE_IN_BYTES = 9 * 1024 * 1024 +SCAN_BATCH_MAX_FILES_COUNT = 1000 +# if we increase this values, the server doesn't allow connecting (ConnectionError) +SCAN_BATCH_MAX_PARALLEL_SCANS = 5 +SCAN_BATCH_SCANS_PER_CPU = 1 + # scan with polling SCAN_POLLING_WAIT_INTERVAL_IN_SECONDS = 5 DEFAULT_SCAN_POLLING_TIMEOUT_IN_SECONDS = 3600 @@ -93,8 +100,6 @@ DEFAULT_SCA_PRE_COMMIT_TIMEOUT_IN_SECONDS = 600 SCA_PRE_COMMIT_TIMEOUT_IN_SECONDS_ENV_VAR_NAME = 'SCA_PRE_COMMIT_TIMEOUT_IN_SECONDS' -PROGRESS_UPDATE_COMMITS_INTERVAL = 100 - # pre receive scan PRE_RECEIVE_MAX_COMMITS_TO_SCAN_COUNT_ENV_VAR_NAME = 'PRE_RECEIVE_MAX_COMMITS_TO_SCAN_COUNT' DEFAULT_PRE_RECEIVE_MAX_COMMITS_TO_SCAN_COUNT = 50 @@ -124,6 +129,9 @@ SKIP_SCAN_FLAG = 'skip-cycode-scan' VERBOSE_SCAN_FLAG = 'verbose' +ISSUE_DETECTED_STATUS_CODE = 1 +NO_ISSUES_STATUS_CODE = 0 + LICENSE_COMPLIANCE_POLICY_ID = '8f681450-49e1-4f7e-85b7-0c8fe84b3a35' PACKAGE_VULNERABILITY_POLICY_ID = '9369d10a-9ac0-48d3-9921-5de7fe9a37a7' diff --git a/cycode/cli/main.py b/cycode/cli/main.py index 9eee8343..4e52edb3 100644 --- a/cycode/cli/main.py +++ b/cycode/cli/main.py @@ -3,9 +3,10 @@ import click import sys -from typing import List, Optional +from typing import List, Optional, TYPE_CHECKING from cycode import __version__ +from cycode.cli.consts import NO_ISSUES_STATUS_CODE, ISSUE_DETECTED_STATUS_CODE from cycode.cli.models import Severity from cycode.cli.config import config from cycode.cli import code_scanner @@ -14,14 +15,17 @@ from cycode.cli.user_settings.user_settings_commands import set_credentials, add_exclusions from cycode.cli.auth.auth_command import authenticate from cycode.cli.utils import scan_utils +from cycode.cli.utils.progress_bar import get_progress_bar from cycode.cyclient import logger +from cycode.cli.utils.progress_bar import logger as progress_bar_logger from cycode.cyclient.cycode_client_base import CycodeClientBase from cycode.cyclient.models import UserAgentOptionScheme from cycode.cyclient.scan_config.scan_config_creator import create_scan_client +if TYPE_CHECKING: + from cycode.cyclient.scan_client import ScanClient + CONTEXT = dict() -ISSUE_DETECTED_STATUS_CODE = 1 -NO_ISSUES_STATUS_CODE = 0 @click.group( @@ -106,25 +110,41 @@ def code_scan(context: click.Context, scan_type, client_id, secret, show_secret, context.obj["soft_fail"] = config["soft_fail"] context.obj["scan_type"] = scan_type + + # save backward compatability with old style command if output is not None: - # save backward compatability with old style command context.obj["output"] = output + if output == "json": + context.obj["no_progress_meter"] = True + context.obj["client"] = get_cycode_client(client_id, secret) context.obj["severity_threshold"] = severity_threshold context.obj["monitor"] = monitor context.obj["report"] = report + _sca_scan_to_context(context, sca_scan) + context.obj["progress_bar"] = get_progress_bar(hidden=context.obj["no_progress_meter"]) + context.obj["progress_bar"].start() + return 1 @code_scan.result_callback() @click.pass_context -def finalize(context: click.Context, *args, **kwargs): - if context.obj["soft_fail"]: +def finalize(context: click.Context, *_, **__): + progress_bar = context.obj.get('progress_bar') + if progress_bar: + progress_bar.stop() + + if context.obj['soft_fail']: sys.exit(0) - sys.exit(ISSUE_DETECTED_STATUS_CODE if _should_fail_scan(context) else NO_ISSUES_STATUS_CODE) + exit_code = NO_ISSUES_STATUS_CODE + if _should_fail_scan(context): + exit_code = ISSUE_DETECTED_STATUS_CODE + + sys.exit(exit_code) @click.group( @@ -139,6 +159,9 @@ def finalize(context: click.Context, *args, **kwargs): @click.option( "--verbose", "-v", is_flag=True, default=False, help="Show detailed logs", ) +@click.option( + '--no-progress-meter', is_flag=True, default=False, help='Do not show the progress meter', +) @click.option( '--output', default='text', @@ -153,23 +176,29 @@ def finalize(context: click.Context, *args, **kwargs): ) @click.version_option(__version__, prog_name="cycode") @click.pass_context -def main_cli(context: click.Context, verbose: bool, output: str, user_agent: Optional[str]): +def main_cli(context: click.Context, verbose: bool, no_progress_meter: bool, output: str, user_agent: Optional[str]): context.ensure_object(dict) configuration_manager = ConfigurationManager() verbose = verbose or configuration_manager.get_verbose_flag() context.obj['verbose'] = verbose + # TODO(MarshalX): rework setting the log level for loggers log_level = logging.DEBUG if verbose else logging.INFO logger.setLevel(log_level) + progress_bar_logger.setLevel(log_level) context.obj['output'] = output + if output == 'json': + no_progress_meter = True + + context.obj['no_progress_meter'] = no_progress_meter if user_agent: user_agent_option = UserAgentOptionScheme().loads(user_agent) CycodeClientBase.enrich_user_agent(user_agent_option.user_agent_suffix) -def get_cycode_client(client_id, client_secret): +def get_cycode_client(client_id: str, client_secret: str) -> 'ScanClient': if not client_id or not client_secret: client_id, client_secret = _get_configured_credentials() if not client_id: diff --git a/cycode/cli/models.py b/cycode/cli/models.py index 011ffe4e..94218175 100644 --- a/cycode/cli/models.py +++ b/cycode/cli/models.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import List, NamedTuple, Dict, Type +from typing import List, NamedTuple, Dict, Type, Optional from cycode.cyclient.models import Detection @@ -58,3 +58,12 @@ class CliError(NamedTuple): class CliResult(NamedTuple): success: bool message: str + + +class LocalScanResult(NamedTuple): + scan_id: str + report_url: Optional[str] + document_detections: List[DocumentDetections] + issue_detected: bool + detections_count: int + relevant_detections_count: int diff --git a/cycode/cli/printers/base_printer.py b/cycode/cli/printers/base_printer.py index e5450e8f..afd46513 100644 --- a/cycode/cli/printers/base_printer.py +++ b/cycode/cli/printers/base_printer.py @@ -1,9 +1,12 @@ from abc import ABC, abstractmethod -from typing import List +from typing import List, TYPE_CHECKING import click -from cycode.cli.models import DocumentDetections, CliResult, CliError +from cycode.cli.models import CliResult, CliError + +if TYPE_CHECKING: + from cycode.cli.models import LocalScanResult class BasePrinter(ABC): @@ -15,7 +18,7 @@ def __init__(self, context: click.Context): self.context = context @abstractmethod - def print_scan_results(self, results: List[DocumentDetections]) -> None: + def print_scan_results(self, local_scan_results: List['LocalScanResult']) -> None: pass @abstractmethod diff --git a/cycode/cli/printers/base_table_printer.py b/cycode/cli/printers/base_table_printer.py index 112a1d4a..4a956e66 100644 --- a/cycode/cli/printers/base_table_printer.py +++ b/cycode/cli/printers/base_table_printer.py @@ -1,18 +1,20 @@ import abc -from typing import List +from typing import List, TYPE_CHECKING import click from cycode.cli.printers.text_printer import TextPrinter -from cycode.cli.models import DocumentDetections, CliError, CliResult +from cycode.cli.models import CliError, CliResult from cycode.cli.printers.base_printer import BasePrinter +if TYPE_CHECKING: + from cycode.cli.models import LocalScanResult + class BaseTablePrinter(BasePrinter, abc.ABC): def __init__(self, context: click.Context): super().__init__(context) self.context = context - self.scan_id: str = context.obj.get('scan_id') self.scan_type: str = context.obj.get('scan_type') self.show_secret: bool = context.obj.get('show_secret', False) @@ -22,22 +24,16 @@ def print_result(self, result: CliResult) -> None: def print_error(self, error: CliError) -> None: TextPrinter(self.context).print_error(error) - def print_scan_results(self, results: List[DocumentDetections]): - click.secho(f'Scan Results: (scan_id: {self.scan_id})') - - if not results: + def print_scan_results(self, local_scan_results: List['LocalScanResult']): + if all(result.issue_detected == 0 for result in local_scan_results): click.secho('Good job! No issues were found!!! 👏👏👏', fg=self.GREEN_COLOR_NAME) return - self._print_results(results) - - report_url = self.context.obj.get('report_url') - if report_url: - click.secho(f'Report URL: {report_url}') + self._print_results(local_scan_results) def _is_git_repository(self) -> bool: return self.context.obj.get('remote_url') is not None @abc.abstractmethod - def _print_results(self, results: List[DocumentDetections]) -> None: + def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: raise NotImplementedError diff --git a/cycode/cli/printers/console_printer.py b/cycode/cli/printers/console_printer.py index 9932e140..06e3ddf7 100644 --- a/cycode/cli/printers/console_printer.py +++ b/cycode/cli/printers/console_printer.py @@ -2,13 +2,14 @@ from typing import List, TYPE_CHECKING from cycode.cli.exceptions.custom_exceptions import CycodeError -from cycode.cli.models import DocumentDetections, CliResult, CliError +from cycode.cli.models import CliResult, CliError from cycode.cli.printers.table_printer import TablePrinter from cycode.cli.printers.sca_table_printer import SCATablePrinter from cycode.cli.printers.json_printer import JsonPrinter from cycode.cli.printers.text_printer import TextPrinter if TYPE_CHECKING: + from cycode.cli.models import LocalScanResult from cycode.cli.printers.base_printer import BasePrinter @@ -31,9 +32,9 @@ def __init__(self, context: click.Context): if self._printer_class is None: raise CycodeError(f'"{self.output_type}" output type is not supported.') - def print_scan_results(self, detections_results_list: List[DocumentDetections]) -> None: + def print_scan_results(self, local_scan_results: List['LocalScanResult']) -> None: printer = self._get_scan_printer() - printer.print_scan_results(detections_results_list) + printer.print_scan_results(local_scan_results) def _get_scan_printer(self) -> 'BasePrinter': printer_class = self._printer_class diff --git a/cycode/cli/printers/json_printer.py b/cycode/cli/printers/json_printer.py index 2e77aba7..c02eff5b 100644 --- a/cycode/cli/printers/json_printer.py +++ b/cycode/cli/printers/json_printer.py @@ -1,18 +1,17 @@ import json -from typing import List +from typing import List, TYPE_CHECKING import click -from cycode.cli.models import DocumentDetections, CliResult, CliError +from cycode.cli.models import CliResult, CliError from cycode.cli.printers.base_printer import BasePrinter from cycode.cyclient.models import DetectionSchema +if TYPE_CHECKING: + from cycode.cli.models import LocalScanResult -class JsonPrinter(BasePrinter): - def __init__(self, context: click.Context): - super().__init__(context) - self.scan_id = context.obj.get('scan_id') +class JsonPrinter(BasePrinter): def print_result(self, result: CliResult) -> None: result = { 'result': result.success, @@ -29,10 +28,11 @@ def print_error(self, error: CliError) -> None: click.secho(self.get_data_json(result)) - def print_scan_results(self, results: List[DocumentDetections]) -> None: + def print_scan_results(self, local_scan_results: List['LocalScanResult']) -> None: detections = [] - for result in results: - detections.extend(result.detections) + for local_scan_result in local_scan_results: + for document_detections in local_scan_result.document_detections: + detections.extend(document_detections.detections) detections_dict = DetectionSchema(many=True).dump(detections) @@ -40,7 +40,7 @@ def print_scan_results(self, results: List[DocumentDetections]) -> None: def _get_json_scan_result(self, detections: dict) -> str: result = { - 'scan_id': str(self.scan_id), + 'scan_id': 'DEPRECATED', # FIXME(MarshalX): we need change JSON struct to support multiple scan results 'detections': detections } diff --git a/cycode/cli/printers/sca_table_printer.py b/cycode/cli/printers/sca_table_printer.py index 7feac6b1..051a2e56 100644 --- a/cycode/cli/printers/sca_table_printer.py +++ b/cycode/cli/printers/sca_table_printer.py @@ -1,14 +1,17 @@ from collections import defaultdict -from typing import List, Dict +from typing import List, Dict, TYPE_CHECKING import click from texttable import Texttable from cycode.cli.consts import LICENSE_COMPLIANCE_POLICY_ID, PACKAGE_VULNERABILITY_POLICY_ID -from cycode.cli.models import DocumentDetections, Detection +from cycode.cli.models import Detection from cycode.cli.printers.base_table_printer import BaseTablePrinter from cycode.cli.utils.string_utils import shortcut_dependency_paths +if TYPE_CHECKING: + from cycode.cli.models import LocalScanResult + SEVERITY_COLUMN = 'Severity' LICENSE_COLUMN = 'License' UPGRADE_COLUMN = 'Upgrade' @@ -26,17 +29,20 @@ class SCATablePrinter(BaseTablePrinter): - def _print_results(self, results: List[DocumentDetections]) -> None: - detections_per_detection_type_id = self._extract_detections_per_detection_type_id(results) + def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: + detections_per_detection_type_id = self._extract_detections_per_detection_type_id(local_scan_results) self._print_detection_per_detection_type_id(detections_per_detection_type_id) @staticmethod - def _extract_detections_per_detection_type_id(results: List[DocumentDetections]) -> Dict[str, List[Detection]]: + def _extract_detections_per_detection_type_id( + local_scan_results: List['LocalScanResult'] + ) -> Dict[str, List[Detection]]: detections_per_detection_type_id = defaultdict(list) - for document_detection in results: - for detection in document_detection.detections: - detections_per_detection_type_id[detection.detection_type_id].append(detection) + for local_scan_result in local_scan_results: + for document_detection in local_scan_result.document_detections: + for detection in document_detection.detections: + detections_per_detection_type_id[detection.detection_type_id].append(detection) return detections_per_detection_type_id @@ -51,7 +57,7 @@ def _print_detection_per_detection_type_id( rows = [] if detection_type_id == PACKAGE_VULNERABILITY_POLICY_ID: - title = "Dependencies Vulnerabilities" + title = 'Dependencies Vulnerabilities' headers = [SEVERITY_COLUMN] + headers headers.extend(PREVIEW_DETECTIONS_COMMON_HEADERS) @@ -61,7 +67,7 @@ def _print_detection_per_detection_type_id( for detection in detections: rows.append(self._get_upgrade_package_vulnerability(detection)) elif detection_type_id == LICENSE_COMPLIANCE_POLICY_ID: - title = "License Compliance" + title = 'License Compliance' headers.extend(PREVIEW_DETECTIONS_COMMON_HEADERS) headers.append(LICENSE_COLUMN) @@ -138,7 +144,7 @@ def _get_upgrade_package_vulnerability(self, detection: Detection) -> List[str]: ] upgrade = '' - if alert.get("first_patched_version"): + if alert.get('first_patched_version'): upgrade = f'{alert.get("vulnerable_requirements")} -> {alert.get("first_patched_version")}' row.append(upgrade) diff --git a/cycode/cli/printers/table_printer.py b/cycode/cli/printers/table_printer.py index 353ce903..12f8f61b 100644 --- a/cycode/cli/printers/table_printer.py +++ b/cycode/cli/printers/table_printer.py @@ -1,4 +1,4 @@ -from typing import List +from typing import List, TYPE_CHECKING import click @@ -7,7 +7,10 @@ from cycode.cli.printers.table import Table from cycode.cli.utils.string_utils import obfuscate_text, get_position_in_line from cycode.cli.consts import SECRET_SCAN_TYPE, INFRA_CONFIGURATION_SCAN_TYPE, SAST_SCAN_TYPE -from cycode.cli.models import DocumentDetections, Detection, Document +from cycode.cli.models import Detection, Document + +if TYPE_CHECKING: + from cycode.cli.models import LocalScanResult # Creation must have strict order. Represents the order of the columns in the table (from left to right) ISSUE_TYPE_COLUMN = ColumnInfoBuilder.build(name='Issue Type') @@ -19,6 +22,8 @@ COLUMN_NUMBER_COLUMN = ColumnInfoBuilder.build(name='Column Number') VIOLATION_LENGTH_COLUMN = ColumnInfoBuilder.build(name='Violation Length') VIOLATION_COLUMN = ColumnInfoBuilder.build(name='Violation') +SCAN_ID_COLUMN = ColumnInfoBuilder.build(name='Scan ID') +REPORT_URL_COLUMN = ColumnInfoBuilder.build(name='Report URL') COLUMN_WIDTHS_CONFIG: ColumnWidthsConfig = { SECRET_SCAN_TYPE: { @@ -27,29 +32,36 @@ FILE_PATH_COLUMN: 2, SECRET_SHA_COLUMN: 2, VIOLATION_COLUMN: 2, + SCAN_ID_COLUMN: 2, }, INFRA_CONFIGURATION_SCAN_TYPE: { ISSUE_TYPE_COLUMN: 4, RULE_ID_COLUMN: 3, FILE_PATH_COLUMN: 3, + SCAN_ID_COLUMN: 2, }, SAST_SCAN_TYPE: { ISSUE_TYPE_COLUMN: 7, RULE_ID_COLUMN: 2, FILE_PATH_COLUMN: 3, + SCAN_ID_COLUMN: 2, }, } class TablePrinter(BaseTablePrinter): - def _print_results(self, results: List[DocumentDetections]) -> None: + def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: table = self._get_table() if self.scan_type in COLUMN_WIDTHS_CONFIG: table.set_cols_width(COLUMN_WIDTHS_CONFIG[self.scan_type]) - for result in results: - for detection in result.detections: - self._enrich_table_with_values(table, detection, result.document) + for local_scan_result in local_scan_results: + for document_detections in local_scan_result.document_detections: + report_url = local_scan_result.report_url if local_scan_result.report_url else 'N/A' + for detection in document_detections.detections: + table.set(REPORT_URL_COLUMN, report_url) + table.set(SCAN_ID_COLUMN, local_scan_result.scan_id) + self._enrich_table_with_values(table, detection, document_detections.document) click.echo(table.get_table().draw()) @@ -61,6 +73,8 @@ def _get_table(self) -> Table: table.add(FILE_PATH_COLUMN) table.add(LINE_NUMBER_COLUMN) table.add(COLUMN_NUMBER_COLUMN) + table.add(SCAN_ID_COLUMN) + table.add(REPORT_URL_COLUMN) if self._is_git_repository(): table.add(COMMIT_SHA_COLUMN) diff --git a/cycode/cli/printers/text_printer.py b/cycode/cli/printers/text_printer.py index c3a655d6..2d25af2f 100644 --- a/cycode/cli/printers/text_printer.py +++ b/cycode/cli/printers/text_printer.py @@ -1,5 +1,5 @@ import math -from typing import List, Optional +from typing import List, Optional, TYPE_CHECKING import click @@ -9,11 +9,13 @@ from cycode.cli.consts import SECRET_SCAN_TYPE, COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES from cycode.cli.utils.string_utils import obfuscate_text, get_position_in_line +if TYPE_CHECKING: + from cycode.cli.models import LocalScanResult + class TextPrinter(BasePrinter): def __init__(self, context: click.Context): super().__init__(context) - self.scan_id: str = context.obj.get('scan_id') self.scan_type: str = context.obj.get('scan_type') self.command_scan_type: str = context.info_name self.show_secret: bool = context.obj.get('show_secret', False) @@ -26,39 +28,46 @@ def print_result(self, result: CliResult) -> None: click.secho(result.message, fg=color) def print_error(self, error: CliError) -> None: - click.secho(error.message, fg=self.RED_COLOR_NAME, nl=False) - - def print_scan_results(self, results: List[DocumentDetections]): - click.secho(f"Scan Results: (scan_id: {self.scan_id})") + click.secho(error.message, fg=self.RED_COLOR_NAME) - if not results: - click.secho("Good job! No issues were found!!! 👏👏👏", fg=self.GREEN_COLOR_NAME) + def print_scan_results(self, local_scan_results: List['LocalScanResult']): + if all(result.issue_detected == 0 for result in local_scan_results): + click.secho('Good job! No issues were found!!! 👏👏👏', fg=self.GREEN_COLOR_NAME) return - for document_detections in results: - self._print_document_detections(document_detections) + for local_scan_result in local_scan_results: + for document_detections in local_scan_result.document_detections: + self._print_document_detections( + document_detections, local_scan_result.scan_id, local_scan_result.report_url + ) - report_url = self.context.obj.get('report_url') - if report_url: - click.secho(f'Report URL: {report_url}') - - def _print_document_detections(self, document_detections: DocumentDetections): + def _print_document_detections( + self, document_detections: DocumentDetections, scan_id: str, report_url: Optional[str] + ): document = document_detections.document lines_to_display = self._get_lines_to_display_count() for detection in document_detections.detections: - self._print_detection_summary(detection, document.path) + self._print_detection_summary(detection, document.path, scan_id, report_url) self._print_detection_code_segment(detection, document, lines_to_display) - def _print_detection_summary(self, detection: Detection, document_path: str): + def _print_detection_summary( + self, detection: Detection, document_path: str, scan_id: str, report_url: Optional[str] + ): detection_name = detection.type if self.scan_type == SECRET_SCAN_TYPE else detection.message + detection_sha = detection.detection_details.get('sha512') detection_sha_message = f'\nSecret SHA: {detection_sha}' if detection_sha else '' + + scan_id_message = f'\nScan ID: {scan_id}' + report_url_message = f'\nReport URL: {report_url}' if report_url else '' + detection_commit_id = detection.detection_details.get('commit_id') detection_commit_id_message = f'\nCommit SHA: {detection_commit_id}' if detection_commit_id else '' + click.echo( f'⛔ Found issue of type: {click.style(detection_name, fg="bright_red", bold=True)} ' f'(rule ID: {detection.detection_rule_id}) in file: {click.format_filename(document_path)} ' - f'{detection_sha_message}{detection_commit_id_message} ⛔' + f'{detection_sha_message}{scan_id_message}{report_url_message}{detection_commit_id_message} ⛔' ) def _print_detection_code_segment(self, detection: Detection, document: Document, code_segment_size: int): diff --git a/cycode/cli/utils/enum_utils.py b/cycode/cli/utils/enum_utils.py new file mode 100644 index 00000000..54a02ffb --- /dev/null +++ b/cycode/cli/utils/enum_utils.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class AutoCountEnum(Enum): + @staticmethod + def _generate_next_value_(name, start, count, last_values): + return count diff --git a/cycode/cli/utils/path_utils.py b/cycode/cli/utils/path_utils.py index d7c4478c..f6bef61b 100644 --- a/cycode/cli/utils/path_utils.py +++ b/cycode/cli/utils/path_utils.py @@ -1,4 +1,4 @@ -from typing import Iterable, List, Optional +from typing import Iterable, List, Optional, AnyStr import pathspec import os from pathlib import Path @@ -65,10 +65,10 @@ def join_paths(path: str, filename: str) -> str: return os.path.join(path, filename) -def get_file_content(file_path: str) -> Optional[str]: +def get_file_content(file_path: str) -> Optional[AnyStr]: try: - with open(file_path, "r", encoding="utf-8") as f: + with open(file_path, 'r', encoding='UTF-8') as f: content = f.read() return content - except FileNotFoundError: + except (FileNotFoundError, UnicodeDecodeError): return None diff --git a/cycode/cli/utils/progress_bar.py b/cycode/cli/utils/progress_bar.py new file mode 100644 index 00000000..196ca9e9 --- /dev/null +++ b/cycode/cli/utils/progress_bar.py @@ -0,0 +1,240 @@ +from abc import ABC, abstractmethod +from enum import auto +from typing import NamedTuple, Dict, TYPE_CHECKING, Optional + +import click + +from cycode.cyclient.config import get_logger +from cycode.cli.utils.enum_utils import AutoCountEnum + +if TYPE_CHECKING: + from click._termui_impl import ProgressBar + + +logger = get_logger('progress bar') + + +class ProgressBarSection(AutoCountEnum): + PREPARE_LOCAL_FILES = auto() + # UPLOAD_FILES = auto() + SCAN = auto() + GENERATE_REPORT = auto() + + def has_next(self) -> bool: + return self.value < len(ProgressBarSection) - 1 + + def next(self) -> 'ProgressBarSection': + return ProgressBarSection(self.value + 1) + + +class ProgressBarSectionInfo(NamedTuple): + section: ProgressBarSection + label: str + start_percent: int + stop_percent: int + + +_PROGRESS_BAR_LENGTH = 100 + +_PROGRESS_BAR_SECTIONS = { + ProgressBarSection.PREPARE_LOCAL_FILES: ProgressBarSectionInfo( + ProgressBarSection.PREPARE_LOCAL_FILES, 'Prepare local files', start_percent=0, stop_percent=5 + ), + # TODO(MarshalX): could be added in the future + # ProgressBarSection.UPLOAD_FILES: ProgressBarSectionInfo( + # ProgressBarSection.UPLOAD_FILES, 'Upload files', start_percent=5, stop_percent=10 + # ), + ProgressBarSection.SCAN: ProgressBarSectionInfo( + ProgressBarSection.SCAN, 'Scan in progress', start_percent=5, stop_percent=95 + ), + ProgressBarSection.GENERATE_REPORT: ProgressBarSectionInfo( + ProgressBarSection.GENERATE_REPORT, 'Generate report', start_percent=95, stop_percent=100 + ), +} + + +def _get_section_length(section: 'ProgressBarSection') -> int: + return _PROGRESS_BAR_SECTIONS[section].stop_percent - _PROGRESS_BAR_SECTIONS[section].start_percent + + +class BaseProgressBar(ABC): + def __init__(self, *args, **kwargs): + pass + + @abstractmethod + def __enter__(self) -> 'BaseProgressBar': + ... + + @abstractmethod + def __exit__(self, *args, **kwargs) -> None: + ... + + @abstractmethod + def start(self) -> None: + ... + + @abstractmethod + def stop(self) -> None: + ... + + @abstractmethod + def set_section_length(self, section: 'ProgressBarSection', length: int) -> None: + ... + + @abstractmethod + def update(self, section: 'ProgressBarSection') -> None: + ... + + +class DummyProgressBar(BaseProgressBar): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def __enter__(self) -> 'DummyProgressBar': + return self + + def __exit__(self, *args, **kwargs) -> None: + pass + + def start(self) -> None: + pass + + def stop(self) -> None: + pass + + def set_section_length(self, section: 'ProgressBarSection', length: int) -> None: + pass + + def update(self, section: 'ProgressBarSection') -> None: + pass + + +class CompositeProgressBar(BaseProgressBar): + def __init__(self): + super().__init__() + self._progress_bar_context_manager = click.progressbar( + length=_PROGRESS_BAR_LENGTH, + item_show_func=self._progress_bar_item_show_func, + update_min_steps=0, + ) + self._progress_bar: Optional['ProgressBar'] = None + self._run = False + + self._section_lengths: Dict[ProgressBarSection, int] = {} + self._section_values: Dict[ProgressBarSection, int] = {} + + self._current_section_value = 0 + self._current_section: ProgressBarSectionInfo = _PROGRESS_BAR_SECTIONS[ProgressBarSection.PREPARE_LOCAL_FILES] + + def __enter__(self) -> 'CompositeProgressBar': + self._progress_bar = self._progress_bar_context_manager.__enter__() + self._run = True + return self + + def __exit__(self, *args, **kwargs) -> None: + self._progress_bar_context_manager.__exit__(*args, **kwargs) + self._run = False + + def start(self) -> None: + if not self._run: + self.__enter__() + + def stop(self) -> None: + if self._run: + self.__exit__(None, None, None) + + def set_section_length(self, section: 'ProgressBarSection', length: int) -> None: + logger.debug(f'set_section_length: {section} {length}') + self._section_lengths[section] = length + + if length == 0: + self._skip_section(section) + + def _skip_section(self, section: 'ProgressBarSection') -> None: + self._progress_bar.update(_get_section_length(section)) + self._maybe_update_current_section() + + def _increment_section_value(self, section: 'ProgressBarSection', value: int) -> None: + self._section_values[section] = self._section_values.get(section, 0) + value + logger.debug( + f'_increment_section_value: {section} +{value}. ' + f'{self._section_values[section]}/{self._section_lengths[section]}' + ) + + def _rerender_progress_bar(self) -> None: + """Used to update label right after changing the progress bar section.""" + self._progress_bar.update(0) + + def _increment_progress(self, section: ProgressBarSection) -> None: + increment_value = self._get_increment_progress_value(section) + + self._current_section_value += increment_value + self._progress_bar.update(increment_value) + + def _maybe_update_current_section(self) -> None: + if not self._current_section.section.has_next(): + return + + max_val = self._section_lengths.get(self._current_section.section, 0) + cur_val = self._section_values.get(self._current_section.section, 0) + if cur_val >= max_val: + next_section = _PROGRESS_BAR_SECTIONS[self._current_section.section.next()] + logger.debug( + f'_update_current_section: {self._current_section.section} -> {next_section.section}' + ) + + self._current_section = next_section + self._current_section_value = 0 + self._rerender_progress_bar() + + def _get_increment_progress_value(self, section: 'ProgressBarSection') -> int: + max_val = self._section_lengths[section] + cur_val = self._section_values[section] + + expected_value = round(_get_section_length(section) * (cur_val / max_val)) + + return expected_value - self._current_section_value + + def _progress_bar_item_show_func(self, _=None) -> str: + return self._current_section.label + + def update(self, section: 'ProgressBarSection', value: int = 1) -> None: + if not self._progress_bar: + raise ValueError('Progress bar is not initialized. Call start() first or use "with" statement.') + + if section not in self._section_lengths: + raise ValueError(f'{section} section is not initialized. Call set_section_length() first.') + if section is not self._current_section.section: + raise ValueError( + f'Previous section is not completed yet. Complete {self._current_section.section} section first.' + ) + + self._increment_section_value(section, value) + self._increment_progress(section) + self._maybe_update_current_section() + + +def get_progress_bar(*, hidden: bool) -> BaseProgressBar: + if hidden: + return DummyProgressBar() + + return CompositeProgressBar() + + +if __name__ == '__main__': + # TODO(MarshalX): cover with tests and remove this code + import time + import random + + bar = get_progress_bar(hidden=False) + bar.start() + + for bar_section in ProgressBarSection: + section_capacity = random.randint(500, 1000) + bar.set_section_length(bar_section, section_capacity) + + for i in range(section_capacity): + time.sleep(0.01) + bar.update(bar_section) + + bar.stop() diff --git a/cycode/cli/utils/scan_batch.py b/cycode/cli/utils/scan_batch.py new file mode 100644 index 00000000..5604d562 --- /dev/null +++ b/cycode/cli/utils/scan_batch.py @@ -0,0 +1,73 @@ +import os +from multiprocessing.pool import ThreadPool +from typing import List, TYPE_CHECKING, Callable, Tuple, Dict + +from cycode.cli.consts import SCAN_BATCH_MAX_SIZE_IN_BYTES, SCAN_BATCH_MAX_FILES_COUNT, SCAN_BATCH_SCANS_PER_CPU, \ + SCAN_BATCH_MAX_PARALLEL_SCANS +from cycode.cli.models import Document +from cycode.cli.utils.progress_bar import ProgressBarSection + +if TYPE_CHECKING: + from cycode.cli.models import LocalScanResult, CliError + from cycode.cli.utils.progress_bar import BaseProgressBar + + +def split_documents_into_batches( + documents: List[Document], + max_size_mb: int = SCAN_BATCH_MAX_SIZE_IN_BYTES, + max_files_count: int = SCAN_BATCH_MAX_FILES_COUNT, +) -> List[List[Document]]: + batches = [] + + current_size = 0 + current_batch = [] + for document in documents: + document_size = len(document.content.encode('UTF-8')) + + if (current_size + document_size > max_size_mb) or (len(current_batch) >= max_files_count): + batches.append(current_batch) + + current_batch = [document] + current_size = document_size + else: + current_batch.append(document) + current_size += document_size + + if current_batch: + batches.append(current_batch) + + return batches + + +def _get_threads_count() -> int: + return min(os.cpu_count() * SCAN_BATCH_SCANS_PER_CPU, SCAN_BATCH_MAX_PARALLEL_SCANS) + + +def run_parallel_batched_scan( + scan_function: Callable[[List[Document]], Tuple[str, 'CliError', 'LocalScanResult']], + documents: List[Document], + progress_bar: 'BaseProgressBar', + max_size_mb: int = SCAN_BATCH_MAX_SIZE_IN_BYTES, + max_files_count: int = SCAN_BATCH_MAX_FILES_COUNT, +) -> Tuple[Dict[str, 'CliError'], List['LocalScanResult']]: + batches = split_documents_into_batches(documents, max_size_mb, max_files_count) + progress_bar.set_section_length(ProgressBarSection.SCAN, len(batches)) # * 3 + # TODO(MarshalX): we should multiply the count of batches in SCAN section because each batch has 3 steps: + # 1. scan creation + # 2. scan completion + # 3. detection creation + # it's not possible yet because not all scan types moved to polling mechanism + # the progress bar could be significant improved (be more dynamic) in the future + + local_scan_results: List['LocalScanResult'] = [] + cli_errors: Dict[str, 'CliError'] = {} + with ThreadPool(processes=_get_threads_count()) as pool: + for scan_id, err, result in pool.imap(scan_function, batches): + if result: + local_scan_results.append(result) + if err: + cli_errors[scan_id] = err + + progress_bar.update(ProgressBarSection.SCAN) + + return cli_errors, local_scan_results diff --git a/cycode/cli/utils/scan_utils.py b/cycode/cli/utils/scan_utils.py index 8541add1..77866c4b 100644 --- a/cycode/cli/utils/scan_utils.py +++ b/cycode/cli/utils/scan_utils.py @@ -1,7 +1,11 @@ import click -def is_scan_failed(context: click.Context): - did_fail = context.obj.get("did_fail") - issue_detected = context.obj.get("issue_detected") +def set_issue_detected(context: click.Context, issue_detected: bool) -> None: + context.obj['issue_detected'] = issue_detected + + +def is_scan_failed(context: click.Context) -> bool: + did_fail = context.obj.get('did_fail') + issue_detected = context.obj.get('issue_detected') return did_fail or issue_detected diff --git a/cycode/cyclient/cycode_client_base.py b/cycode/cyclient/cycode_client_base.py index 1259e250..dc37a258 100644 --- a/cycode/cyclient/cycode_client_base.py +++ b/cycode/cyclient/cycode_client_base.py @@ -1,6 +1,7 @@ import platform from typing import Dict +from cycode.cyclient import logger from requests import Response, request, exceptions from cycode import __version__ @@ -72,21 +73,26 @@ def _execute( method: str, endpoint: str, headers: dict = None, + without_auth: bool = False, **kwargs ) -> Response: url = self.build_full_url(self.api_url, endpoint) + logger.debug(f'Executing {method.upper()} request to {url}') try: + headers = self.get_request_headers(headers, without_auth=without_auth) response = request( - method=method, url=url, timeout=self.timeout, headers=self.get_request_headers(headers), **kwargs + method=method, url=url, timeout=self.timeout, headers=headers, **kwargs ) + logger.debug(f'Response {response.status_code} from {url}. Content: {response.text}') + response.raise_for_status() return response except Exception as e: self._handle_exception(e) - def get_request_headers(self, additional_headers: dict = None) -> dict: + def get_request_headers(self, additional_headers: dict = None, **kwargs) -> dict: if additional_headers is None: return self.MANDATORY_HEADERS.copy() return {**self.MANDATORY_HEADERS, **additional_headers} diff --git a/cycode/cyclient/cycode_token_based_client.py b/cycode/cyclient/cycode_token_based_client.py index e3da7cc2..f1043c57 100644 --- a/cycode/cyclient/cycode_token_based_client.py +++ b/cycode/cyclient/cycode_token_based_client.py @@ -32,17 +32,18 @@ def refresh_api_token_if_needed(self) -> None: def refresh_api_token(self) -> None: auth_response = self.post( url_path=f'api/v1/auth/api-token', - body={'clientId': self.client_id, 'secret': self.client_secret} + body={'clientId': self.client_id, 'secret': self.client_secret}, + without_auth=True, ) auth_response_data = auth_response.json() self._api_token = auth_response_data['token'] self._expires_in = arrow.utcnow().shift(seconds=auth_response_data['expires_in'] * 0.8) - def get_request_headers(self, additional_headers: dict = None) -> dict: + def get_request_headers(self, additional_headers: dict = None, without_auth=False) -> dict: headers = super().get_request_headers(additional_headers=additional_headers) - if not self.lock.locked(): + if not without_auth: headers = self._add_auth_header(headers) return headers diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index 3eb29cf3..6b754e4a 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -38,6 +38,7 @@ def zipped_file_scan(self, scan_type: str, zip_file: InMemoryZip, scan_id: str, data={'scan_id': scan_id, 'is_git_diff': is_git_diff, 'scan_parameters': json.dumps(scan_parameters)}, files=files ) + return self.parse_zipped_file_scan_response(response) def zipped_file_scan_async(self, zip_file: InMemoryZip, scan_type: str, scan_parameters: dict, @@ -72,14 +73,20 @@ def get_scan_details(self, scan_id: str) -> models.ScanDetailsResponse: return models.ScanDetailsResponseSchema().load(response.json()) def get_scan_detections(self, scan_id: str) -> List[dict]: + url_path = f'{self.scan_config.get_detections_prefix()}/{self.DETECTIONS_SERVICE_CONTROLLER_PATH}' + params = {'scan_id': scan_id} + + page_size = 200 + detections = [] + page_number = 0 - page_size = 200 last_response_size = 0 - while page_number == 0 or last_response_size == page_size: - url_path = f'{self.scan_config.get_detections_prefix()}/{self.DETECTIONS_SERVICE_CONTROLLER_PATH}?scan_id={scan_id}&page_size={page_size}&page_number={page_number}' - response = self.scan_cycode_client.get(url_path=url_path).json() + params['page_size'] = page_size + params['page_number'] = page_number + + response = self.scan_cycode_client.get(url_path=url_path, params=params).json() detections.extend(response) page_number += 1 diff --git a/cycode/cyclient/scan_config/scan_config_creator.py b/cycode/cyclient/scan_config/scan_config_creator.py index 5f0b8a7c..ef20ee1e 100644 --- a/cycode/cyclient/scan_config/scan_config_creator.py +++ b/cycode/cyclient/scan_config/scan_config_creator.py @@ -1,3 +1,5 @@ +from typing import Tuple + from ..config import dev_mode from ..config_dev import DEV_CYCODE_API_URL from ..cycode_dev_based_client import CycodeDevBasedClient @@ -6,7 +8,7 @@ from ..scan_config.scan_config_base import DefaultScanConfig, DevScanConfig -def create_scan_client(client_id, client_secret): +def create_scan_client(client_id: str, client_secret: str) -> ScanClient: if dev_mode: scan_cycode_client, scan_config = create_scan_for_dev_env() else: @@ -16,13 +18,13 @@ def create_scan_client(client_id, client_secret): scan_config=scan_config) -def create_scan(client_id, client_secret): +def create_scan(client_id: str, client_secret: str) -> Tuple[CycodeTokenBasedClient, DefaultScanConfig]: scan_cycode_client = CycodeTokenBasedClient(client_id, client_secret) scan_config = DefaultScanConfig() return scan_cycode_client, scan_config -def create_scan_for_dev_env(): +def create_scan_for_dev_env() -> Tuple[CycodeDevBasedClient, DevScanConfig]: scan_cycode_client = CycodeDevBasedClient(DEV_CYCODE_API_URL) scan_config = DevScanConfig() return scan_cycode_client, scan_config diff --git a/poetry.lock b/poetry.lock index 51b3c7d7..acbcbcd3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,10 +1,9 @@ -# This file is automatically @generated by Poetry and should not be changed by hand. +# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. [[package]] name = "altgraph" version = "0.17.3" description = "Python graph (network) package" -category = "dev" optional = false python-versions = "*" files = [ @@ -16,7 +15,6 @@ files = [ name = "arrow" version = "0.17.0" description = "Better dates & times for Python" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ @@ -31,7 +29,6 @@ python-dateutil = ">=2.7.0" name = "binaryornot" version = "0.4.4" description = "Ultra-lightweight pure Python package to check if a file is binary or text." -category = "main" optional = false python-versions = "*" files = [ @@ -46,7 +43,6 @@ chardet = ">=3.0.2" name = "certifi" version = "2023.5.7" description = "Python package for providing Mozilla's CA Bundle." -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -58,7 +54,6 @@ files = [ name = "chardet" version = "5.1.0" description = "Universal encoding detector for Python 3" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -70,7 +65,6 @@ files = [ name = "charset-normalizer" version = "3.1.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -category = "main" optional = false python-versions = ">=3.7.0" files = [ @@ -155,7 +149,6 @@ files = [ name = "click" version = "8.1.3" description = "Composable command line interface toolkit" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -171,7 +164,6 @@ importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." -category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -183,7 +175,6 @@ files = [ name = "coverage" version = "7.2.5" description = "Code coverage measurement for Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -247,7 +238,6 @@ toml = ["tomli"] name = "dunamai" version = "1.16.1" description = "Dynamic version generation" -category = "dev" optional = false python-versions = ">=3.5,<4.0" files = [ @@ -263,7 +253,6 @@ packaging = ">=20.9" name = "exceptiongroup" version = "1.1.1" description = "Backport of PEP 654 (exception groups)" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -278,7 +267,6 @@ test = ["pytest (>=6)"] name = "gitdb" version = "4.0.10" description = "Git Object Database" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -293,7 +281,6 @@ smmap = ">=3.0.1,<6" name = "gitpython" version = "3.1.31" description = "GitPython is a Python library used to interact with Git repositories" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -305,33 +292,10 @@ files = [ gitdb = ">=4.0.1,<5" typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\""} -[[package]] -name = "halo" -version = "0.0.31" -description = "Beautiful terminal spinners in Python" -category = "main" -optional = false -python-versions = ">=3.4" -files = [ - {file = "halo-0.0.31-py2-none-any.whl", hash = "sha256:5350488fb7d2aa7c31a1344120cee67a872901ce8858f60da7946cef96c208ab"}, - {file = "halo-0.0.31.tar.gz", hash = "sha256:7b67a3521ee91d53b7152d4ee3452811e1d2a6321975137762eb3d70063cc9d6"}, -] - -[package.dependencies] -colorama = ">=0.3.9" -log-symbols = ">=0.0.14" -six = ">=1.12.0" -spinners = ">=0.0.24" -termcolor = ">=1.1.0" - -[package.extras] -ipython = ["IPython (==5.7.0)", "ipywidgets (==7.1.0)"] - [[package]] name = "idna" version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" -category = "main" optional = false python-versions = ">=3.5" files = [ @@ -343,7 +307,6 @@ files = [ name = "importlib-metadata" version = "6.6.0" description = "Read metadata from Python packages" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -364,7 +327,6 @@ testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packag name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -372,26 +334,10 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] -[[package]] -name = "log-symbols" -version = "0.0.14" -description = "Colored symbols for various log levels for Python" -category = "main" -optional = false -python-versions = "*" -files = [ - {file = "log_symbols-0.0.14-py3-none-any.whl", hash = "sha256:4952106ff8b605ab7d5081dd2c7e6ca7374584eff7086f499c06edd1ce56dcca"}, - {file = "log_symbols-0.0.14.tar.gz", hash = "sha256:cf0bbc6fe1a8e53f0d174a716bc625c4f87043cc21eb55dd8a740cfe22680556"}, -] - -[package.dependencies] -colorama = ">=0.3.9" - [[package]] name = "macholib" version = "1.16.2" description = "Mach-O header analysis and editing" -category = "dev" optional = false python-versions = "*" files = [ @@ -406,7 +352,6 @@ altgraph = ">=0.17" name = "marshmallow" version = "3.8.0" description = "A lightweight library for converting complex datatypes to and from native Python datatypes." -category = "main" optional = false python-versions = ">=3.5" files = [ @@ -424,7 +369,6 @@ tests = ["pytest", "pytz", "simplejson"] name = "mock" version = "4.0.3" description = "Rolling backport of unittest.mock for all Pythons" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -441,7 +385,6 @@ test = ["pytest (<5.4)", "pytest-cov"] name = "packaging" version = "23.1" description = "Core utilities for Python packages" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -453,7 +396,6 @@ files = [ name = "pathspec" version = "0.8.1" description = "Utility library for gitignore style pattern matching of file paths." -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ @@ -465,7 +407,6 @@ files = [ name = "pefile" version = "2023.2.7" description = "Python PE parsing module" -category = "dev" optional = false python-versions = ">=3.6.0" files = [ @@ -477,7 +418,6 @@ files = [ name = "pluggy" version = "1.0.0" description = "plugin and hook calling mechanisms for python" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -496,7 +436,6 @@ testing = ["pytest", "pytest-benchmark"] name = "pyinstaller" version = "5.11.0" description = "PyInstaller bundles a Python application and all its dependencies into a single package." -category = "dev" optional = false python-versions = "<3.12,>=3.7" files = [ @@ -531,7 +470,6 @@ hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"] name = "pyinstaller-hooks-contrib" version = "2023.3" description = "Community maintained hooks for PyInstaller" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -543,7 +481,6 @@ files = [ name = "pytest" version = "7.3.1" description = "pytest: simple powerful testing with Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -567,7 +504,6 @@ testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "no name = "pytest-mock" version = "3.10.0" description = "Thin-wrapper around the mock package for easier use with pytest" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -585,7 +521,6 @@ dev = ["pre-commit", "pytest-asyncio", "tox"] name = "python-dateutil" version = "2.8.2" description = "Extensions to the standard Python datetime module" -category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ @@ -600,7 +535,6 @@ six = ">=1.5" name = "pywin32-ctypes" version = "0.2.0" description = "" -category = "dev" optional = false python-versions = "*" files = [ @@ -612,7 +546,6 @@ files = [ name = "pyyaml" version = "6.0" description = "YAML parser and emitter for Python" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -662,7 +595,6 @@ files = [ name = "requests" version = "2.31.0" description = "Python HTTP for Humans." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -684,7 +616,6 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] name = "responses" version = "0.23.1" description = "A utility library for mocking out the `requests` Python library." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -706,7 +637,6 @@ tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asy name = "setuptools" version = "67.7.2" description = "Easily download, build, install, upgrade, and uninstall Python packages" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -723,7 +653,6 @@ testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs ( name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -735,7 +664,6 @@ files = [ name = "smmap" version = "5.0.0" description = "A pure Python implementation of a sliding window memory map manager" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -743,38 +671,10 @@ files = [ {file = "smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"}, ] -[[package]] -name = "spinners" -version = "0.0.24" -description = "Spinners for terminals" -category = "main" -optional = false -python-versions = "*" -files = [ - {file = "spinners-0.0.24-py3-none-any.whl", hash = "sha256:2fa30d0b72c9650ad12bbe031c9943b8d441e41b4f5602b0ec977a19f3290e98"}, - {file = "spinners-0.0.24.tar.gz", hash = "sha256:1eb6aeb4781d72ab42ed8a01dcf20f3002bf50740d7154d12fb8c9769bf9e27f"}, -] - -[[package]] -name = "termcolor" -version = "2.3.0" -description = "ANSI color formatting for output in terminal" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "termcolor-2.3.0-py3-none-any.whl", hash = "sha256:3afb05607b89aed0ffe25202399ee0867ad4d3cb4180d98aaf8eefa6a5f7d475"}, - {file = "termcolor-2.3.0.tar.gz", hash = "sha256:b5b08f68937f138fe92f6c089b99f1e2da0ae56c52b78bf7075fd95420fd9a5a"}, -] - -[package.extras] -tests = ["pytest", "pytest-cov"] - [[package]] name = "texttable" version = "1.6.7" description = "module to create simple ASCII tables" -category = "main" optional = false python-versions = "*" files = [ @@ -786,7 +686,6 @@ files = [ name = "tomli" version = "2.0.1" description = "A lil' TOML parser" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -798,7 +697,6 @@ files = [ name = "types-pyyaml" version = "6.0.12.9" description = "Typing stubs for PyYAML" -category = "dev" optional = false python-versions = "*" files = [ @@ -810,7 +708,6 @@ files = [ name = "typing-extensions" version = "4.5.0" description = "Backported and Experimental Type Hints for Python 3.7+" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -822,7 +719,6 @@ files = [ name = "urllib3" version = "2.0.2" description = "HTTP library with thread-safe connection pooling, file post, and more." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -840,7 +736,6 @@ zstd = ["zstandard (>=0.18.0)"] name = "zipp" version = "3.15.0" description = "Backport of pathlib-compatible object wrapper for zip files" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -855,4 +750,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = ">=3.7,<3.12" -content-hash = "ab665eabb7b281d5fcfef66b81ab5882b511282e822129e2408b1653f5044dd5" +content-hash = "13ef296063bf977819668451d2ba229a9009e3cee5478f5a9d2c4aef3e5e2b2f" diff --git a/pyproject.toml b/pyproject.toml index e95712de..39d01626 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,6 @@ pathspec = ">=0.8.0,<0.9.0" gitpython = ">=3.1.30,<3.2.0" arrow = ">=0.17.0,<0.18.0" binaryornot = ">=0.4.4,<0.5.0" -halo = "==0.0.31" texttable = ">=1.6.7,<1.7.0" requests = ">=2.24,<3.0" diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py index e018819f..f2784626 100644 --- a/tests/cli/test_main.py +++ b/tests/cli/test_main.py @@ -34,7 +34,9 @@ def test_passing_output_option( responses.add(get_zipped_file_scan_response(get_zipped_file_scan_url(scan_type, scan_client))) responses.add(api_token_response) - # scan report is not mocked. This raise connection error on attempt to report scan. it doesn't perform real request + # Scan report is not mocked. + # This raises connection error on the attempt to report scan. + # It doesn't perform real request args = ['scan', '--soft-fail', 'path', str(_PATH_TO_SCAN)] @@ -58,4 +60,4 @@ def test_passing_output_option( output = json.loads(result.output) assert 'scan_id' in output else: - assert 'Scan Results' in result.output + assert 'Scan ID' in result.output diff --git a/tests/cyclient/test_scan_client.py b/tests/cyclient/test_scan_client.py index afbbdabe..d4e11128 100644 --- a/tests/cyclient/test_scan_client.py +++ b/tests/cyclient/test_scan_client.py @@ -52,7 +52,7 @@ def get_zipped_file_scan_response(url: str, scan_id: UUID = None) -> responses.R json_response = { 'did_detect': True, - 'scan_id': scan_id.hex, # not always as expected due to _get_scan_id and passing scan_id to cxt of CLI + 'scan_id': str(scan_id), # not always as expected due to _get_scan_id and passing scan_id to cxt of CLI 'detections_per_file': [ { 'file_name': str(_ZIP_CONTENT_PATH.joinpath('secrets.py')), @@ -86,7 +86,7 @@ def get_zipped_file_scan_response(url: str, scan_id: UUID = None) -> responses.R def test_get_service_name(scan_client: ScanClient): - # TODO(Marshal): get_service_name should be removed from ScanClient? Because it exists in ScanConfig + # TODO(MarshalX): get_service_name should be removed from ScanClient? Because it exists in ScanConfig assert scan_client.get_service_name('secret') == 'secret' assert scan_client.get_service_name('iac') == 'iac' assert scan_client.get_service_name('sca') == 'scans' @@ -102,11 +102,10 @@ def test_zipped_file_scan(scan_type: str, scan_client: ScanClient, api_token_res responses.add(api_token_response) # mock token based client responses.add(get_zipped_file_scan_response(url, expected_scan_id)) - # TODO(MarshalX): fix wrong type hint? UUID instead of str zipped_file_scan_response = scan_client.zipped_file_scan( - scan_type, zip_file, scan_id=expected_scan_id, scan_parameters={} + scan_type, zip_file, scan_id=str(expected_scan_id), scan_parameters={} ) - assert zipped_file_scan_response.scan_id == expected_scan_id.hex + assert zipped_file_scan_response.scan_id == str(expected_scan_id) @pytest.mark.parametrize('scan_type', config['scans']['supported_scans']) From ea798720532ba8a17224fb9644ef5317cefb5236 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 5 Jul 2023 13:42:04 +0200 Subject: [PATCH 005/257] CM-24996 - Integrate Black code formatter (#133) --- .github/workflows/black.yml | 39 ++ cycode/__init__.py | 2 +- cycode/cli/auth/auth_command.py | 10 +- cycode/cli/auth/auth_manager.py | 17 +- cycode/cli/code_scanner.py | 388 ++++++++++-------- cycode/cli/consts.py | 83 +++- cycode/cli/exceptions/custom_exceptions.py | 6 +- .../maven/base_restore_maven_dependencies.py | 23 +- .../maven/restore_gradle_dependencies.py | 3 +- .../maven/restore_maven_dependencies.py | 40 +- cycode/cli/helpers/sca_code_scanner.py | 48 ++- cycode/cli/main.py | 159 ++++--- cycode/cli/models.py | 10 +- cycode/cli/printers/json_printer.py | 14 +- cycode/cli/printers/sca_table_printer.py | 12 +- cycode/cli/printers/table_printer.py | 6 +- cycode/cli/printers/text_printer.py | 73 ++-- cycode/cli/user_settings/base_file_manager.py | 1 - .../cli/user_settings/config_file_manager.py | 25 +- .../user_settings/configuration_manager.py | 27 +- .../cli/user_settings/credentials_manager.py | 6 +- .../user_settings/user_settings_commands.py | 103 +++-- cycode/cli/utils/progress_bar.py | 4 +- cycode/cli/utils/scan_batch.py | 24 +- cycode/cli/utils/shell_executor.py | 2 +- cycode/cli/utils/task_timer.py | 14 +- cycode/cli/zip_file.py | 2 +- cycode/cyclient/config.py | 5 +- cycode/cyclient/config_dev.py | 2 +- cycode/cyclient/cycode_client_base.py | 34 +- cycode/cyclient/cycode_dev_based_client.py | 1 - cycode/cyclient/models.py | 67 ++- cycode/cyclient/scan_client.py | 39 +- .../cyclient/scan_config/scan_config_base.py | 3 - .../scan_config/scan_config_creator.py | 3 +- cycode/cyclient/utils.py | 2 +- poetry.lock | 145 ++++++- pyproject.toml | 25 +- tests/__init__.py | 2 +- tests/cli/test_code_scanner.py | 20 +- tests/cli/test_main.py | 2 +- tests/conftest.py | 9 +- tests/cyclient/test_auth_client.py | 23 +- tests/cyclient/test_client_base.py | 6 +- tests/cyclient/test_dev_based_client.py | 4 +- tests/cyclient/test_scan_client.py | 20 +- tests/cyclient/test_token_based_client.py | 4 +- .../test_configuration_manager.py | 22 +- .../test_user_settings_commands.py | 46 ++- 49 files changed, 1001 insertions(+), 624 deletions(-) create mode 100644 .github/workflows/black.yml diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml new file mode 100644 index 00000000..05208c66 --- /dev/null +++ b/.github/workflows/black.yml @@ -0,0 +1,39 @@ +name: Black (code formatter) + +on: [ pull_request, push ] + +jobs: + black: + runs-on: ubuntu-latest + + steps: + - name: Run Cimon + uses: cycodelabs/cimon-action@v0 + with: + client-id: ${{ secrets.CIMON_CLIENT_ID }} + secret: ${{ secrets.CIMON_SECRET }} + prevent: true + allowed-hosts: > + files.pythonhosted.org + install.python-poetry.org + pypi.org + + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: 3.7 + + - name: Setup Poetry + uses: snok/install-poetry@v1 + + - name: Install dependencies + run: poetry install + + - name: Check code style of package + run: poetry run black --check cycode + + - name: Check code style of tests + run: poetry run black --check tests diff --git a/cycode/__init__.py b/cycode/__init__.py index 9d89bffd..4ce71ef1 100644 --- a/cycode/__init__.py +++ b/cycode/__init__.py @@ -1 +1 @@ -__version__ = '0.0.0' # DON'T TOUCH. Placeholder. Will be filled automatically on poetry build from Git Tag +__version__ = '0.0.0' # DON'T TOUCH. Placeholder. Will be filled automatically on poetry build from Git Tag diff --git a/cycode/cli/auth/auth_command.py b/cycode/cli/auth/auth_command.py index 37b6c3f6..e19085fb 100644 --- a/cycode/cli/auth/auth_command.py +++ b/cycode/cli/auth/auth_command.py @@ -13,7 +13,7 @@ @click.group(invoke_without_command=True) @click.pass_context def authenticate(context: click.Context): - """ Authenticates your machine to associate CLI with your cycode account """ + """Authenticates your machine to associate CLI with your cycode account""" if context.invoked_subcommand is not None: # if it is a subcommand do nothing return @@ -33,7 +33,7 @@ def authenticate(context: click.Context): @authenticate.command(name='check') @click.pass_context def authorization_check(context: click.Context): - """ Check your machine associating CLI with your cycode account """ + """Check your machine associating CLI with your cycode account""" printer = ConsolePrinter(context) passed_auth_check_res = CliResult(success=True, message='You are authorized') @@ -59,12 +59,10 @@ def _handle_exception(context: click.Context, e: Exception): errors: CliErrors = { AuthProcessError: CliError( - code='auth_error', - message='Authentication failed. Please try again later using the command `cycode auth`' + code='auth_error', message='Authentication failed. Please try again later using the command `cycode auth`' ), NetworkError: CliError( - code='cycode_error', - message='Authentication failed. Please try again later using the command `cycode auth`' + code='cycode_error', message='Authentication failed. Please try again later using the command `cycode auth`' ), } diff --git a/cycode/cli/auth/auth_manager.py b/cycode/cli/auth/auth_manager.py index da2ca3b4..d8d018d8 100644 --- a/cycode/cli/auth/auth_manager.py +++ b/cycode/cli/auth/auth_manager.py @@ -80,11 +80,7 @@ def save_api_token(self, api_token: ApiToken): def _build_login_url(self, code_challenge: str, session_id: str): app_url = self.configuration_manager.get_cycode_app_url() login_url = f'{app_url}/account/sign-in' - query_params = { - 'source': 'cycode_cli', - 'code_challenge': code_challenge, - 'session_id': session_id - } + query_params = {'source': 'cycode_cli', 'code_challenge': code_challenge, 'session_id': session_id} # TODO(MarshalX). Use auth_client instead and don't depend on "requests" lib here request = Request(url=login_url, params=query_params) return request.prepare().url @@ -95,9 +91,12 @@ def _generate_pkce_code_pair(self) -> (str, str): return code_challenge, code_verifier def _is_api_token_process_completed(self, api_token_polling_response: ApiTokenGenerationPollingResponse) -> bool: - return api_token_polling_response is not None \ - and api_token_polling_response.status == self.COMPLETED_POLLING_STATUS + return ( + api_token_polling_response is not None + and api_token_polling_response.status == self.COMPLETED_POLLING_STATUS + ) def _is_api_token_process_failed(self, api_token_polling_response: ApiTokenGenerationPollingResponse) -> bool: - return api_token_polling_response is not None \ - and api_token_polling_response.status == self.FAILED_POLLING_STATUS + return ( + api_token_polling_response is not None and api_token_polling_response.status == self.FAILED_POLLING_STATUS + ) diff --git a/cycode/cli/code_scanner.py b/cycode/cli/code_scanner.py index f2219716..47d64ccc 100644 --- a/cycode/cli/code_scanner.py +++ b/cycode/cli/code_scanner.py @@ -19,8 +19,14 @@ from cycode.cli.config import configuration_manager from cycode.cli.utils.progress_bar import ProgressBarSection from cycode.cli.utils.scan_utils import set_issue_detected -from cycode.cli.utils.path_utils import is_sub_path, is_binary_file, get_file_size, get_relevant_files_in_path, \ - get_path_by_os, get_file_content +from cycode.cli.utils.path_utils import ( + is_sub_path, + is_binary_file, + get_file_size, + get_relevant_files_in_path, + get_path_by_os, + get_file_content, +) from cycode.cli.utils.scan_batch import run_parallel_batched_scan from cycode.cli.utils.string_utils import get_content_size, is_binary_content from cycode.cli.utils.task_timer import TimeoutAfter @@ -43,14 +49,17 @@ @click.command() @click.argument("path", nargs=1, type=click.STRING, required=True) -@click.option('--branch', '-b', - default=None, - help='Branch to scan, if not set scanning the default branch', - type=str, - required=False) +@click.option( + '--branch', + '-b', + default=None, + help='Branch to scan, if not set scanning the default branch', + type=str, + required=False, +) @click.pass_context def scan_repository(context: click.Context, path: str, branch: str): - """ Scan git repository including its history """ + """Scan git repository including its history""" try: logger.debug('Starting repository scan process, %s', {'path': path, 'branch': branch}) @@ -88,19 +97,18 @@ def scan_repository(context: click.Context, path: str, branch: str): @click.command() -@click.argument("path", - nargs=1, - type=click.STRING, - required=True) -@click.option("--commit_range", "-r", - help='Scan a commit range in this git repository, by default cycode scans all ' - 'commit history (example: HEAD~1)', - type=click.STRING, - default="--all", - required=False) +@click.argument("path", nargs=1, type=click.STRING, required=True) +@click.option( + "--commit_range", + "-r", + help='Scan a commit range in this git repository, by default cycode scans all ' 'commit history (example: HEAD~1)', + type=click.STRING, + default="--all", + required=False, +) @click.pass_context def scan_repository_commit_history(context: click.Context, path: str, commit_range: str): - """ Scan all the commits history in this git repository """ + """Scan all the commits history in this git repository""" try: logger.debug('Starting commit history scan process, %s', {'path': path, 'commit_range': commit_range}) return scan_commit_range(context, path=path, commit_range=commit_range) @@ -143,20 +151,18 @@ def scan_commit_range(context: click.Context, path: str, commit_range: str, max_ commit_documents_to_scan = [] for blob in diff: blob_path = get_path_by_os(os.path.join(path, get_diff_file_path(blob))) - commit_documents_to_scan.append(Document( - path=blob_path, - content=blob.diff.decode('UTF-8', errors='replace'), - is_git_diff_format=True, - unique_id=commit_id - )) + commit_documents_to_scan.append( + Document( + path=blob_path, + content=blob.diff.decode('UTF-8', errors='replace'), + is_git_diff_format=True, + unique_id=commit_id, + ) + ) logger.debug( 'Found all relevant files in commit %s', - { - 'path': path, - 'commit_range': commit_range, - 'commit_id': commit_id - } + {'path': path, 'commit_range': commit_range, 'commit_id': commit_id}, ) documents_to_scan.extend(exclude_irrelevant_documents_to_scan(context, commit_documents_to_scan)) @@ -171,8 +177,8 @@ def scan_commit_range(context: click.Context, path: str, commit_range: str, max_ @click.command() @click.pass_context def scan_ci(context: click.Context): - """ Execute scan in a CI environment which relies on the - CYCODE_TOKEN and CYCODE_REPO_LOCATION environment variables """ + """Execute scan in a CI environment which relies on the + CYCODE_TOKEN and CYCODE_REPO_LOCATION environment variables""" return scan_commit_range(context, path=os.getcwd(), commit_range=get_commit_range()) @@ -180,7 +186,7 @@ def scan_ci(context: click.Context): @click.argument("path", nargs=1, type=click.STRING, required=True) @click.pass_context def scan_path(context: click.Context, path): - """ Scan the files in the path supplied in the command """ + """Scan the files in the path supplied in the command""" logger.debug('Starting path scan process, %s', {'path': path}) files_to_scan = get_relevant_files_in_path(path=path, exclude_patterns=["**/.git/**", "**/.cycode/**"]) files_to_scan = exclude_irrelevant_files(context, files_to_scan) @@ -192,7 +198,7 @@ def scan_path(context: click.Context, path): @click.argument('ignored_args', nargs=-1, type=click.UNPROCESSED) @click.pass_context def pre_commit_scan(context: click.Context, ignored_args: List[str]): - """ Use this command to scan the content that was not committed yet """ + """Use this command to scan the content that was not committed yet""" scan_type = context.obj['scan_type'] progress_bar = context.obj['progress_bar'] @@ -216,7 +222,7 @@ def pre_commit_scan(context: click.Context, ignored_args: List[str]): @click.argument("ignored_args", nargs=-1, type=click.UNPROCESSED) @click.pass_context def pre_receive_scan(context: click.Context, ignored_args: List[str]): - """ Use this command to scan commits on the server side before pushing them to the repository """ + """Use this command to scan commits on the server side before pushing them to the repository""" try: scan_type = context.obj['scan_type'] if scan_type != consts.SECRET_SCAN_TYPE: @@ -225,7 +231,8 @@ def pre_receive_scan(context: click.Context, ignored_args: List[str]): if should_skip_pre_receive_scan(): logger.info( "A scan has been skipped as per your request." - " Please note that this may leave your system vulnerable to secrets that have not been detected") + " Please note that this may leave your system vulnerable to secrets that have not been detected" + ) return if is_verbose_mode_requested_in_pre_receive_scan(): @@ -242,8 +249,7 @@ def pre_receive_scan(context: click.Context, ignored_args: List[str]): commit_range = calculate_pre_receive_commit_range(branch_update_details) if not commit_range: logger.info( - 'No new commits found for pushed branch, %s', - {'branch_update_details': branch_update_details} + 'No new commits found for pushed branch, %s', {'branch_update_details': branch_update_details} ) return @@ -260,9 +266,13 @@ def scan_sca_pre_commit(context: click.Context): git_head_documents = exclude_irrelevant_documents_to_scan(context, git_head_documents) pre_committed_documents = exclude_irrelevant_documents_to_scan(context, pre_committed_documents) sca_code_scanner.perform_pre_hook_range_scan_actions(git_head_documents, pre_committed_documents) - return scan_commit_range_documents(context, git_head_documents, pre_committed_documents, - scan_parameters, - configuration_manager.get_sca_pre_commit_timeout_in_seconds()) + return scan_commit_range_documents( + context, + git_head_documents, + pre_committed_documents, + scan_parameters, + configuration_manager.get_sca_pre_commit_timeout_in_seconds(), + ) def scan_sca_commit_range(context: click.Context, path: str, commit_range: str): @@ -275,11 +285,13 @@ def scan_sca_commit_range(context: click.Context, path: str, commit_range: str): ) from_commit_documents = exclude_irrelevant_documents_to_scan(context, from_commit_documents) to_commit_documents = exclude_irrelevant_documents_to_scan(context, to_commit_documents) - sca_code_scanner.perform_pre_commit_range_scan_actions(path, from_commit_documents, from_commit_rev, - to_commit_documents, to_commit_rev) + sca_code_scanner.perform_pre_commit_range_scan_actions( + path, from_commit_documents, from_commit_rev, to_commit_documents, to_commit_rev + ) - return scan_commit_range_documents(context, from_commit_documents, to_commit_documents, - scan_parameters=scan_parameters) + return scan_commit_range_documents( + context, from_commit_documents, to_commit_documents, scan_parameters=scan_parameters + ) def scan_disk_files(context: click.Context, path: str, files_to_scan: List[str]): @@ -310,7 +322,7 @@ def set_issue_detected_by_scan_results(context: click.Context, scan_results: Lis def _get_scan_documents_thread_func( - context: click.Context, is_git_diff: bool, is_commit_range: bool, scan_parameters: dict + context: click.Context, is_git_diff: bool, is_commit_range: bool, scan_parameters: dict ) -> Callable[[List[Document]], Tuple[str, CliError, LocalScanResult]]: cycode_client = context.obj['client'] scan_type = context.obj['scan_type'] @@ -353,12 +365,20 @@ def _scan_batch_thread_func(batch: List[Document]) -> Tuple[str, CliError, Local 'all_violations_count': detections_count, 'relevant_violations_count': relevant_detections_count, 'scan_id': scan_id, - 'zip_file_size': zip_file_size - } + 'zip_file_size': zip_file_size, + }, ) _report_scan_status( - cycode_client, scan_type, scan_id, scan_completed, relevant_detections_count, - detections_count, len(batch), zip_file_size, command_scan_type, error_message + cycode_client, + scan_type, + scan_id, + scan_completed, + relevant_detections_count, + detections_count, + len(batch), + zip_file_size, + command_scan_type, + error_message, ) return scan_id, error, local_scan_result @@ -367,11 +387,11 @@ def _scan_batch_thread_func(batch: List[Document]) -> Tuple[str, CliError, Local def scan_documents( - context: click.Context, - documents_to_scan: List[Document], - is_git_diff: bool = False, - is_commit_range: bool = False, - scan_parameters: Optional[dict] = None, + context: click.Context, + documents_to_scan: List[Document], + is_git_diff: bool = False, + is_commit_range: bool = False, + scan_parameters: Optional[dict] = None, ) -> None: progress_bar = context.obj['progress_bar'] @@ -399,7 +419,7 @@ def scan_documents( click.secho( 'Unfortunately, Cycode was unable to complete the full scan. ' 'Please note that not all results may be available:', - fg='red' + fg='red', ) for scan_id, error in errors.items(): click.echo(f'- {scan_id}: ', nl=False) @@ -407,11 +427,11 @@ def scan_documents( def scan_commit_range_documents( - context: click.Context, - from_documents_to_scan: List[Document], - to_documents_to_scan: List[Document], - scan_parameters: Optional[dict] = None, - timeout: Optional[int] = None + context: click.Context, + from_documents_to_scan: List[Document], + to_documents_to_scan: List[Document], + scan_parameters: Optional[dict] = None, + timeout: Optional[int] = None, ) -> None: cycode_client = context.obj['client'] scan_type = context.obj['scan_type'] @@ -442,8 +462,12 @@ def scan_commit_range_documents( ) scan_result = perform_commit_range_scan_async( - cycode_client, from_commit_zipped_documents, to_commit_zipped_documents, - scan_type, scan_parameters, timeout + cycode_client, + from_commit_zipped_documents, + to_commit_zipped_documents, + scan_type, + scan_parameters, + timeout, ) progress_bar.update(ProgressBarSection.SCAN) @@ -464,8 +488,9 @@ def scan_commit_range_documents( _handle_exception(context, e) error_message = str(e) - zip_file_size = getsizeof(from_commit_zipped_documents.in_memory_zip) + \ - getsizeof(to_commit_zipped_documents.in_memory_zip) + zip_file_size = getsizeof(from_commit_zipped_documents.in_memory_zip) + getsizeof( + to_commit_zipped_documents.in_memory_zip + ) detections_count = relevant_detections_count = 0 if local_scan_result: @@ -479,13 +504,20 @@ def scan_commit_range_documents( 'all_violations_count': detections_count, 'relevant_violations_count': relevant_detections_count, 'scan_id': scan_id, - 'zip_file_size': zip_file_size - } + 'zip_file_size': zip_file_size, + }, ) _report_scan_status( - cycode_client, scan_type, local_scan_result.scan_id, scan_completed, - local_scan_result.relevant_detections_count, local_scan_result.detections_count, len(to_documents_to_scan), - zip_file_size, scan_command_type, error_message + cycode_client, + scan_type, + local_scan_result.scan_id, + scan_completed, + local_scan_result.relevant_detections_count, + local_scan_result.detections_count, + len(to_documents_to_scan), + zip_file_size, + scan_command_type, + error_message, ) @@ -494,11 +526,11 @@ def should_scan_documents(from_documents_to_scan: List[Document], to_documents_t def create_local_scan_result( - scan_result: ZippedFileScanResult, - documents_to_scan: List[Document], - command_scan_type: str, - scan_type: str, - severity_threshold: str, + scan_result: ZippedFileScanResult, + documents_to_scan: List[Document], + command_scan_type: str, + scan_type: str, + severity_threshold: str, ) -> LocalScanResult: document_detections = get_document_detections(scan_result, documents_to_scan) relevant_document_detections_list = exclude_irrelevant_document_detections( @@ -516,12 +548,12 @@ def create_local_scan_result( document_detections=relevant_document_detections_list, issue_detected=len(relevant_document_detections_list) > 0, detections_count=detections_count, - relevant_detections_count=relevant_detections_count + relevant_detections_count=relevant_detections_count, ) def perform_pre_scan_documents_actions( - context: click.Context, scan_type: str, documents_to_scan: List[Document], is_git_diff: bool = False + context: click.Context, scan_type: str, documents_to_scan: List[Document], is_git_diff: bool = False ) -> None: if scan_type == consts.SCA_SCAN_TYPE: logger.debug(f'Perform pre scan document actions') @@ -535,8 +567,9 @@ def zip_documents_to_scan(scan_type: str, zip_file: InMemoryZip, documents: List zip_file_size = getsizeof(zip_file.in_memory_zip) validate_zip_file_size(scan_type, zip_file_size) - logger.debug('adding file to zip, %s', - {'index': index, 'filename': document.path, 'unique_id': document.unique_id}) + logger.debug( + 'adding file to zip, %s', {'index': index, 'filename': document.path, 'unique_id': document.unique_id} + ) zip_file.append(document.path, document.unique_id, document.content) zip_file.close() @@ -556,13 +589,13 @@ def validate_zip_file_size(scan_type: str, zip_file_size: int) -> None: def perform_scan( - cycode_client: 'ScanClient', - zipped_documents: InMemoryZip, - scan_type: str, - scan_id: str, - is_git_diff: bool, - is_commit_range: bool, - scan_parameters: dict + cycode_client: 'ScanClient', + zipped_documents: InMemoryZip, + scan_type: str, + scan_id: str, + is_git_diff: bool, + is_commit_range: bool, + scan_parameters: dict, ) -> ZippedFileScanResult: if scan_type in (consts.SCA_SCAN_TYPE, consts.SAST_SCAN_TYPE): return perform_scan_async(cycode_client, zipped_documents, scan_type, scan_parameters) @@ -574,10 +607,7 @@ def perform_scan( def perform_scan_async( - cycode_client: 'ScanClient', - zipped_documents: InMemoryZip, - scan_type: str, - scan_parameters: dict + cycode_client: 'ScanClient', zipped_documents: InMemoryZip, scan_type: str, scan_parameters: dict ) -> ZippedFileScanResult: scan_async_result = cycode_client.zipped_file_scan_async(zipped_documents, scan_type, scan_parameters) logger.debug("scan request has been triggered successfully, scan id: %s", scan_async_result.scan_id) @@ -586,12 +616,12 @@ def perform_scan_async( def perform_commit_range_scan_async( - cycode_client: 'ScanClient', - from_commit_zipped_documents: InMemoryZip, - to_commit_zipped_documents: InMemoryZip, - scan_type: str, - scan_parameters: dict, - timeout: int = None + cycode_client: 'ScanClient', + from_commit_zipped_documents: InMemoryZip, + to_commit_zipped_documents: InMemoryZip, + scan_type: str, + scan_parameters: dict, + timeout: int = None, ) -> ZippedFileScanResult: scan_async_result = cycode_client.multiple_zipped_file_scan_async( from_commit_zipped_documents, to_commit_zipped_documents, scan_type, scan_parameters @@ -602,7 +632,7 @@ def perform_commit_range_scan_async( def poll_scan_results( - cycode_client: 'ScanClient', scan_id: str, polling_timeout: Optional[int] = None + cycode_client: 'ScanClient', scan_id: str, polling_timeout: Optional[int] = None ) -> ZippedFileScanResult: if polling_timeout is None: polling_timeout = configuration_manager.get_scan_polling_timeout_in_seconds() @@ -643,7 +673,7 @@ def print_results(context: click.Context, local_scan_results: List[LocalScanResu def get_document_detections( - scan_result: ZippedFileScanResult, documents_to_scan: List[Document] + scan_result: ZippedFileScanResult, documents_to_scan: List[Document] ) -> List[DocumentDetections]: logger.debug('Get document detections') @@ -655,18 +685,13 @@ def get_document_detections( logger.debug('Going to find document of violated file, %s', {'file_name': file_name, 'commit_id': commit_id}) document = _get_document_by_file_name(documents_to_scan, file_name, commit_id) - document_detections.append( - DocumentDetections(document=document, detections=detections_per_file.detections) - ) + document_detections.append(DocumentDetections(document=document, detections=detections_per_file.detections)) return document_detections def exclude_irrelevant_document_detections( - document_detections_list: List[DocumentDetections], - scan_type: str, - command_scan_type: str, - severity_threshold: str + document_detections_list: List[DocumentDetections], scan_type: str, command_scan_type: str, severity_threshold: str ) -> List[DocumentDetections]: relevant_document_detections_list = [] for document_detections in document_detections_list: @@ -698,7 +723,8 @@ def parse_pre_receive_input() -> str: pre_receive_input = sys.stdin.read().strip() if not pre_receive_input: raise ValueError( - "Pre receive input was not found. Make sure that you are using this command only in pre-receive hook") + "Pre receive input was not found. Make sure that you are using this command only in pre-receive hook" + ) # each line represents a branch update request, handle the first one only # TODO(MichalBor): support case of multiple update branch requests @@ -759,7 +785,7 @@ def get_default_scan_parameters(context: click.Context) -> dict: "monitor": context.obj.get("monitor"), "report": context.obj.get("report"), "package_vulnerabilities": context.obj.get("package-vulnerabilities"), - "license_compliance": context.obj.get("license-compliance") + "license_compliance": context.obj.get("license-compliance"), } @@ -784,9 +810,7 @@ def try_get_git_remote_url(path: str) -> Optional[dict]: return None -def exclude_irrelevant_documents_to_scan( - context: click.Context, documents_to_scan: List[Document] -) -> List[Document]: +def exclude_irrelevant_documents_to_scan(context: click.Context, documents_to_scan: List[Document]) -> List[Document]: logger.debug('Excluding irrelevant documents to scan') scan_type = context.obj['scan_type'] @@ -811,7 +835,7 @@ def exclude_irrelevant_files(context: click.Context, filenames: List[str]) -> Li def exclude_irrelevant_detections( - detections: List[Detection], scan_type: str, command_scan_type: str, severity_threshold: str + detections: List[Detection], scan_type: str, command_scan_type: str, severity_threshold: str ) -> List[Detection]: relevant_detections = _exclude_detections_by_exclusions_configuration(detections, scan_type) relevant_detections = _exclude_detections_by_scan_type(relevant_detections, scan_type, command_scan_type) @@ -821,7 +845,7 @@ def exclude_irrelevant_detections( def _exclude_detections_by_severity( - detections: List[Detection], scan_type: str, severity_threshold: str + detections: List[Detection], scan_type: str, severity_threshold: str ) -> List[Detection]: if scan_type != consts.SCA_SCAN_TYPE or severity_threshold is None: return detections @@ -836,7 +860,7 @@ def _exclude_detections_by_severity( def _exclude_detections_by_scan_type( - detections: List[Detection], scan_type: str, command_scan_type: str + detections: List[Detection], scan_type: str, command_scan_type: str ) -> List[Detection]: if command_scan_type == consts.PRE_COMMIT_COMMAND_SCAN_TYPE: return exclude_detections_in_deleted_lines(detections) @@ -883,7 +907,7 @@ def get_pre_commit_modified_documents(progress_bar: 'BaseProgressBar') -> Tuple[ def get_commit_range_modified_documents( - progress_bar: 'BaseProgressBar', path: str, from_commit_rev: str, to_commit_rev: str + progress_bar: 'BaseProgressBar', path: str, from_commit_rev: str, to_commit_rev: str ) -> Tuple[List[Document], List[Document]]: from_commit_documents = [] to_commit_documents = [] @@ -915,30 +939,37 @@ def get_commit_range_modified_documents( def _should_exclude_detection(detection: Detection, exclusions: Dict) -> bool: exclusions_by_value = exclusions.get(consts.EXCLUSIONS_BY_VALUE_SECTION_NAME, []) if _is_detection_sha_configured_in_exclusions(detection, exclusions_by_value): - logger.debug('Going to ignore violations because is in the values to ignore list, %s', - {'sha': detection.detection_details.get('sha512', '')}) + logger.debug( + 'Going to ignore violations because is in the values to ignore list, %s', + {'sha': detection.detection_details.get('sha512', '')}, + ) return True exclusions_by_sha = exclusions.get(consts.EXCLUSIONS_BY_SHA_SECTION_NAME, []) if _is_detection_sha_configured_in_exclusions(detection, exclusions_by_sha): - logger.debug('Going to ignore violations because is in the shas to ignore list, %s', - {'sha': detection.detection_details.get('sha512', '')}) + logger.debug( + 'Going to ignore violations because is in the shas to ignore list, %s', + {'sha': detection.detection_details.get('sha512', '')}, + ) return True exclusions_by_rule = exclusions.get(consts.EXCLUSIONS_BY_RULE_SECTION_NAME, []) if exclusions_by_rule: detection_rule = detection.detection_rule_id if detection_rule in exclusions_by_rule: - logger.debug('Going to ignore violations because is in the shas to ignore list, %s', - {'detection_rule': detection_rule}) + logger.debug( + 'Going to ignore violations because is in the shas to ignore list, %s', + {'detection_rule': detection_rule}, + ) return True exclusions_by_package = exclusions.get(consts.EXCLUSIONS_BY_PACKAGE_SECTION_NAME, []) if exclusions_by_package: package = _get_package_name(detection) if package in exclusions_by_package: - logger.debug('Going to ignore violations because is in the packages to ignore list, %s', - {'package': package}) + logger.debug( + 'Going to ignore violations because is in the packages to ignore list, %s', {'package': package} + ) return True return False @@ -950,8 +981,9 @@ def _is_detection_sha_configured_in_exclusions(detection, exclusions: List[str]) def _is_path_configured_in_exclusions(scan_type: str, file_path: str) -> bool: - exclusions_by_path = \ - configuration_manager.get_exclusions_by_scan_type(scan_type).get(consts.EXCLUSIONS_BY_PATH_SECTION_NAME, []) + exclusions_by_path = configuration_manager.get_exclusions_by_scan_type(scan_type).get( + consts.EXCLUSIONS_BY_PATH_SECTION_NAME, [] + ) for exclusion_path in exclusions_by_path: if is_sub_path(exclusion_path, file_path): return True @@ -971,10 +1003,7 @@ def _get_package_name(detection: Detection) -> str: def _is_file_relevant_for_sca_scan(filename: str) -> bool: if any([sca_excluded_path in filename for sca_excluded_path in consts.SCA_EXCLUDED_PATHS]): - logger.debug( - "file is irrelevant because it is from node_modules's inner path, %s", - {'filename': filename} - ) + logger.debug("file is irrelevant because it is from node_modules's inner path, %s", {'filename': filename}) return False return True @@ -982,28 +1011,23 @@ def _is_file_relevant_for_sca_scan(filename: str) -> bool: def _is_relevant_file_to_scan(scan_type: str, filename: str) -> bool: if _is_subpath_of_cycode_configuration_folder(filename): - logger.debug("file is irrelevant because it is in cycode configuration directory, %s", - {'filename': filename}) + logger.debug("file is irrelevant because it is in cycode configuration directory, %s", {'filename': filename}) return False if _is_path_configured_in_exclusions(scan_type, filename): - logger.debug("file is irrelevant because the file path is in the ignore paths list, %s", - {'filename': filename}) + logger.debug("file is irrelevant because the file path is in the ignore paths list, %s", {'filename': filename}) return False if not _is_file_extension_supported(scan_type, filename): - logger.debug("file is irrelevant because the file extension is not supported, %s", - {'filename': filename}) + logger.debug("file is irrelevant because the file extension is not supported, %s", {'filename': filename}) return False if is_binary_file(filename): - logger.debug("file is irrelevant because it is binary file, %s", - {'filename': filename}) + logger.debug("file is irrelevant because it is binary file, %s", {'filename': filename}) return False if scan_type != consts.SCA_SCAN_TYPE and _does_file_exceed_max_size_limit(filename): - logger.debug("file is irrelevant because its exceeded max size limit, %s", - {'filename': filename}) + logger.debug("file is irrelevant because its exceeded max size limit, %s", {'filename': filename}) return False if scan_type == consts.SCA_SCAN_TYPE and not _is_file_relevant_for_sca_scan(filename): @@ -1014,28 +1038,27 @@ def _is_relevant_file_to_scan(scan_type: str, filename: str) -> bool: def _is_relevant_document_to_scan(scan_type: str, filename: str, content: str) -> bool: if _is_subpath_of_cycode_configuration_folder(filename): - logger.debug("document is irrelevant because it is in cycode configuration directory, %s", - {'filename': filename}) + logger.debug( + "document is irrelevant because it is in cycode configuration directory, %s", {'filename': filename} + ) return False if _is_path_configured_in_exclusions(scan_type, filename): - logger.debug("document is irrelevant because the document path is in the ignore paths list, %s", - {'filename': filename}) + logger.debug( + "document is irrelevant because the document path is in the ignore paths list, %s", {'filename': filename} + ) return False if not _is_file_extension_supported(scan_type, filename): - logger.debug("document is irrelevant because the file extension is not supported, %s", - {'filename': filename}) + logger.debug("document is irrelevant because the file extension is not supported, %s", {'filename': filename}) return False if is_binary_content(content): - logger.debug("document is irrelevant because it is binary, %s", - {'filename': filename}) + logger.debug("document is irrelevant because it is binary, %s", {'filename': filename}) return False if scan_type != consts.SCA_SCAN_TYPE and _does_document_exceed_max_size_limit(content): - logger.debug("document is irrelevant because its exceeded max size limit, %s", - {'filename': filename}) + logger.debug("document is irrelevant because its exceeded max size limit, %s", {'filename': filename}) return False return True @@ -1044,14 +1067,19 @@ def _is_file_extension_supported(scan_type: str, filename: str) -> bool: filename = filename.lower() if scan_type == consts.INFRA_CONFIGURATION_SCAN_TYPE: - return any(filename.endswith(supported_file_extension) - for supported_file_extension in consts.INFRA_CONFIGURATION_SCAN_SUPPORTED_FILES) + return any( + filename.endswith(supported_file_extension) + for supported_file_extension in consts.INFRA_CONFIGURATION_SCAN_SUPPORTED_FILES + ) elif scan_type == consts.SCA_SCAN_TYPE: - return any(filename.endswith(supported_file) - for supported_file in consts.SCA_CONFIGURATION_SCAN_SUPPORTED_FILES) + return any( + filename.endswith(supported_file) for supported_file in consts.SCA_CONFIGURATION_SCAN_SUPPORTED_FILES + ) - return all(not filename.endswith(file_extension_to_ignore) - for file_extension_to_ignore in consts.SECRET_SCAN_FILE_EXTENSIONS_TO_IGNORE) + return all( + not filename.endswith(file_extension_to_ignore) + for file_extension_to_ignore in consts.SECRET_SCAN_FILE_EXTENSIONS_TO_IGNORE + ) def _does_file_exceed_max_size_limit(filename: str) -> bool: @@ -1063,7 +1091,7 @@ def _does_document_exceed_max_size_limit(content: str) -> bool: def _get_document_by_file_name( - documents: List[Document], file_name: str, unique_id: Optional[str] = None + documents: List[Document], file_name: str, unique_id: Optional[str] = None ) -> Optional[Document]: for document in documents: if _normalize_file_path(document.path) == _normalize_file_path(file_name) and document.unique_id == unique_id: @@ -1073,9 +1101,11 @@ def _get_document_by_file_name( def _is_subpath_of_cycode_configuration_folder(filename: str) -> bool: - return is_sub_path(configuration_manager.global_config_file_manager.get_config_directory_path(), filename) \ - or is_sub_path(configuration_manager.local_config_file_manager.get_config_directory_path(), filename) \ + return ( + is_sub_path(configuration_manager.global_config_file_manager.get_config_directory_path(), filename) + or is_sub_path(configuration_manager.local_config_file_manager.get_config_directory_path(), filename) or filename.endswith(ConfigFileManager.get_config_file_route()) + ) def _handle_exception(context: click.Context, e: Exception, *, return_exception: bool = False) -> Optional[CliError]: @@ -1089,32 +1119,32 @@ def _handle_exception(context: click.Context, e: Exception, *, return_exception: soft_fail=True, code='cycode_error', message='Cycode was unable to complete this scan. ' - 'Please try again by executing the `cycode scan` command' + 'Please try again by executing the `cycode scan` command', ), custom_exceptions.ScanAsyncError: CliError( soft_fail=True, code='scan_error', message='Cycode was unable to complete this scan. ' - 'Please try again by executing the `cycode scan` command' + 'Please try again by executing the `cycode scan` command', ), custom_exceptions.HttpUnauthorizedError: CliError( soft_fail=True, code='auth_error', message='Unable to authenticate to Cycode, your token is either invalid or has expired. ' - 'Please re-generate your token and reconfigure it by running the `cycode configure` command' + 'Please re-generate your token and reconfigure it by running the `cycode configure` command', ), custom_exceptions.ZipTooLargeError: CliError( soft_fail=True, code='zip_too_large_error', message='The path you attempted to scan exceeds the current maximum scanning size cap (10MB). ' - 'Please try ignoring irrelevant paths using the ‘cycode ignore --by-path’ command ' - 'and execute the scan again' + 'Please try ignoring irrelevant paths using the ‘cycode ignore --by-path’ command ' + 'and execute the scan again', ), InvalidGitRepositoryError: CliError( soft_fail=False, code='invalid_git_error', message='The path you supplied does not correlate to a git repository. ' - 'Should you still wish to scan this path, use: ‘cycode scan path ’' + 'Should you still wish to scan this path, use: ‘cycode scan path ’', ), } @@ -1139,9 +1169,18 @@ def _handle_exception(context: click.Context, e: Exception, *, return_exception: raise click.ClickException(str(e)) -def _report_scan_status(cycode_client: 'ScanClient', scan_type: str, scan_id: str, scan_completed: bool, - output_detections_count: int, all_detections_count: int, files_to_scan_count: int, - zip_size: int, command_scan_type: str, error_message: Optional[str]) -> None: +def _report_scan_status( + cycode_client: 'ScanClient', + scan_type: str, + scan_id: str, + scan_completed: bool, + output_detections_count: int, + all_detections_count: int, + files_to_scan_count: int, + zip_size: int, + command_scan_type: str, + error_message: Optional[str], +) -> None: try: end_scan_time = time.time() scan_status = { @@ -1154,7 +1193,7 @@ def _report_scan_status(cycode_client: 'ScanClient', scan_type: str, scan_id: st 'scan_command_type': command_scan_type, 'operation_system': platform(), 'error_message': error_message, - 'scan_type': scan_type + 'scan_type': scan_type, } cycode_client.report_scan_status(scan_type, scan_id, scan_status) @@ -1175,7 +1214,7 @@ def _does_severity_match_severity_threshold(severity: str, severity_threshold: s def _get_scan_result( - cycode_client: 'ScanClient', scan_id: str, scan_details: 'ScanDetailsResponse' + cycode_client: 'ScanClient', scan_id: str, scan_details: 'ScanDetailsResponse' ) -> ZippedFileScanResult: if not scan_details.detections_count: return init_default_scan_result(scan_id, scan_details.metadata) @@ -1187,16 +1226,13 @@ def _get_scan_result( did_detect=True, detections_per_file=_map_detections_per_file(scan_detections), scan_id=scan_id, - report_url=_try_get_report_url(scan_details.metadata) + report_url=_try_get_report_url(scan_details.metadata), ) def init_default_scan_result(scan_id: str, scan_metadata: Optional[str] = None) -> ZippedFileScanResult: return ZippedFileScanResult( - did_detect=False, - detections_per_file=[], - scan_id=scan_id, - report_url=_try_get_report_url(scan_metadata) + did_detect=False, detections_per_file=[], scan_id=scan_id, report_url=_try_get_report_url(scan_metadata) ) @@ -1253,8 +1289,10 @@ def _map_detections_per_file(detections: List[dict]) -> List[DetectionsPerFile]: logger.debug("Failed to parse detection: %s", str(e)) continue - return [DetectionsPerFile(file_name=file_name, detections=file_detections) - for file_name, file_detections in detections_per_files.items()] + return [ + DetectionsPerFile(file_name=file_name, detections=file_detections) + for file_name, file_detections in detections_per_files.items() + ] def _get_file_name_from_detection(detection: dict) -> str: diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 1959d4ae..f7e4a834 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -7,32 +7,75 @@ SCA_SCAN_TYPE = "sca" SAST_SCAN_TYPE = "sast" -INFRA_CONFIGURATION_SCAN_SUPPORTED_FILES = [ - '.tf', '.tf.json', '.json', '.yaml', '.yml', 'dockerfile' -] +INFRA_CONFIGURATION_SCAN_SUPPORTED_FILES = ['.tf', '.tf.json', '.json', '.yaml', '.yml', 'dockerfile'] SECRET_SCAN_FILE_EXTENSIONS_TO_IGNORE = [ - '.7z', '.bmp', '.bz2', '.dmg', '.exe', '.gif', '.gz', '.ico', '.jar', '.jpg', '.jpeg', '.png', '.rar', - '.realm', '.s7z', '.svg', '.tar', '.tif', '.tiff', '.webp', '.zi', '.lock', '.css', '.less', '.dll', - '.enc', '.deb', '.obj', '.model' + '.7z', + '.bmp', + '.bz2', + '.dmg', + '.exe', + '.gif', + '.gz', + '.ico', + '.jar', + '.jpg', + '.jpeg', + '.png', + '.rar', + '.realm', + '.s7z', + '.svg', + '.tar', + '.tif', + '.tiff', + '.webp', + '.zi', + '.lock', + '.css', + '.less', + '.dll', + '.enc', + '.deb', + '.obj', + '.model', ] SCA_CONFIGURATION_SCAN_SUPPORTED_FILES = [ - 'cargo.lock', 'cargo.toml', - 'composer.json', 'composer.lock', - 'go.sum', 'go.mod', 'gopkg.lock', - 'pom.xml', 'build.gradle', 'gradle.lockfile', 'build.gradle.kts', - 'package.json', 'package-lock.json', 'yarn.lock', 'npm-shrinkwrap.json', - 'packages.config', 'project.assets.json', 'packages.lock.json', 'nuget.config', '.csproj', - 'gemfile', 'gemfile.lock', - 'build.sbt', 'build.scala', 'build.sbt.lock', - 'pyproject.toml', 'poetry.lock', - 'pipfile', 'pipfile.lock', 'requirements.txt', 'setup.py' + 'cargo.lock', + 'cargo.toml', + 'composer.json', + 'composer.lock', + 'go.sum', + 'go.mod', + 'gopkg.lock', + 'pom.xml', + 'build.gradle', + 'gradle.lockfile', + 'build.gradle.kts', + 'package.json', + 'package-lock.json', + 'yarn.lock', + 'npm-shrinkwrap.json', + 'packages.config', + 'project.assets.json', + 'packages.lock.json', + 'nuget.config', + '.csproj', + 'gemfile', + 'gemfile.lock', + 'build.sbt', + 'build.scala', + 'build.sbt.lock', + 'pyproject.toml', + 'poetry.lock', + 'pipfile', + 'pipfile.lock', + 'requirements.txt', + 'setup.py', ] -SCA_EXCLUDED_PATHS = [ - 'node_modules' -] +SCA_EXCLUDED_PATHS = ['node_modules'] PROJECT_FILES_BY_ECOSYSTEM_MAP = { "crates": ["Cargo.lock", "Cargo.toml"], @@ -47,7 +90,7 @@ "pypi_poetry": ["pyproject.toml", "poetry.lock"], "pypi_pipenv": ["Pipfile", "Pipfile.lock"], "pypi_requirements": ["requirements.txt"], - "pypi_setup": ["setup.py"] + "pypi_setup": ["setup.py"], } COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES = [SECRET_SCAN_TYPE, SCA_SCAN_TYPE] diff --git a/cycode/cli/exceptions/custom_exceptions.py b/cycode/cli/exceptions/custom_exceptions.py index d80cf26a..8ae2236f 100644 --- a/cycode/cli/exceptions/custom_exceptions.py +++ b/cycode/cli/exceptions/custom_exceptions.py @@ -13,8 +13,10 @@ def __init__(self, status_code: int, error_message: str, response: Response): super().__init__(self.error_message) def __str__(self): - return f'error occurred during the request. status code: {self.status_code}, error message: ' \ - f'{self.error_message}' + return ( + f'error occurred during the request. status code: {self.status_code}, error message: ' + f'{self.error_message}' + ) class ScanAsyncError(CycodeError): diff --git a/cycode/cli/helpers/maven/base_restore_maven_dependencies.py b/cycode/cli/helpers/maven/base_restore_maven_dependencies.py index 484df810..42bd9cc7 100644 --- a/cycode/cli/helpers/maven/base_restore_maven_dependencies.py +++ b/cycode/cli/helpers/maven/base_restore_maven_dependencies.py @@ -17,17 +17,14 @@ def execute_command(command: List[str], file_name: str, command_timeout: int) -> try: dependencies = shell(command, command_timeout) except Exception as e: - logger.debug('Failed to restore dependencies shell comment. %s', - {'filename': file_name, 'exception': str(e)}) + logger.debug('Failed to restore dependencies shell comment. %s', {'filename': file_name, 'exception': str(e)}) return None return dependencies class BaseRestoreMavenDependencies(ABC): - - def __init__(self, context: click.Context, is_git_diff: bool, - command_timeout: int): + def __init__(self, context: click.Context, is_git_diff: bool, command_timeout: int): self.context = context self.is_git_diff = is_git_diff self.command_timeout = command_timeout @@ -37,8 +34,11 @@ def restore(self, document: Document) -> Optional[Document]: return restore_dependencies_document def get_manifest_file_path(self, document: Document) -> str: - return join_paths(self.context.params.get('path'), document.path) if self.context.obj.get( - 'monitor') else document.path + return ( + join_paths(self.context.params.get('path'), document.path) + if self.context.obj.get('monitor') + else document.path + ) @abstractmethod def is_project(self, document: Document) -> bool: @@ -54,8 +54,9 @@ def get_lock_file_name(self) -> str: def try_restore_dependencies(self, document: Document) -> Optional[Document]: manifest_file_path = self.get_manifest_file_path(document) - document = Document(build_dep_tree_path(document.path, self.get_lock_file_name()), - execute_command(self.get_command(manifest_file_path), manifest_file_path, - self.command_timeout), - self.is_git_diff) + document = Document( + build_dep_tree_path(document.path, self.get_lock_file_name()), + execute_command(self.get_command(manifest_file_path), manifest_file_path, self.command_timeout), + self.is_git_diff, + ) return document diff --git a/cycode/cli/helpers/maven/restore_gradle_dependencies.py b/cycode/cli/helpers/maven/restore_gradle_dependencies.py index 1e61ee7f..6a60faad 100644 --- a/cycode/cli/helpers/maven/restore_gradle_dependencies.py +++ b/cycode/cli/helpers/maven/restore_gradle_dependencies.py @@ -11,8 +11,7 @@ class RestoreGradleDependencies(BaseRestoreMavenDependencies): - def __init__(self, context: click.Context, is_git_diff: bool, - command_timeout: int): + def __init__(self, context: click.Context, is_git_diff: bool, command_timeout: int): super().__init__(context, is_git_diff, command_timeout) def is_project(self, document: Document) -> bool: diff --git a/cycode/cli/helpers/maven/restore_maven_dependencies.py b/cycode/cli/helpers/maven/restore_maven_dependencies.py index 8ab21ca3..51e2aa96 100644 --- a/cycode/cli/helpers/maven/restore_maven_dependencies.py +++ b/cycode/cli/helpers/maven/restore_maven_dependencies.py @@ -3,8 +3,11 @@ import click -from cycode.cli.helpers.maven.base_restore_maven_dependencies import BaseRestoreMavenDependencies, build_dep_tree_path, \ - execute_command +from cycode.cli.helpers.maven.base_restore_maven_dependencies import ( + BaseRestoreMavenDependencies, + build_dep_tree_path, + execute_command, +) from cycode.cli.models import Document from cycode.cli.utils.path_utils import get_file_dir, get_file_content, join_paths @@ -14,8 +17,7 @@ class RestoreMavenDependencies(BaseRestoreMavenDependencies): - def __init__(self, context: click.Context, is_git_diff: bool, - command_timeout: int): + def __init__(self, context: click.Context, is_git_diff: bool, command_timeout: int): super().__init__(context, is_git_diff, command_timeout) def is_project(self, document: Document) -> bool: @@ -31,24 +33,23 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: restore_dependencies_document = super().try_restore_dependencies(document) manifest_file_path = self.get_manifest_file_path(document) if document.content is None: - restore_dependencies_document = self.restore_from_secondary_command(document, manifest_file_path, - restore_dependencies_document) + restore_dependencies_document = self.restore_from_secondary_command( + document, manifest_file_path, restore_dependencies_document + ) else: restore_dependencies_document.content = get_file_content( - join_paths(get_file_dir(manifest_file_path), self.get_lock_file_name())) + join_paths(get_file_dir(manifest_file_path), self.get_lock_file_name()) + ) return restore_dependencies_document def restore_from_secondary_command(self, document, manifest_file_path, restore_dependencies_document): # TODO(MarshalX): does it even work? Missing argument secondary_restore_command = create_secondary_restore_command(manifest_file_path) - backup_restore_content = execute_command( - secondary_restore_command, - manifest_file_path, - self.command_timeout) - restore_dependencies_document = Document(build_dep_tree_path(document.path, MAVEN_DEP_TREE_FILE_NAME), - backup_restore_content, - self.is_git_diff) + backup_restore_content = execute_command(secondary_restore_command, manifest_file_path, self.command_timeout) + restore_dependencies_document = Document( + build_dep_tree_path(document.path, MAVEN_DEP_TREE_FILE_NAME), backup_restore_content, self.is_git_diff + ) restore_dependencies = None if restore_dependencies_document.content is not None: restore_dependencies = restore_dependencies_document @@ -58,5 +59,12 @@ def restore_from_secondary_command(self, document, manifest_file_path, restore_d def create_secondary_restore_command(self, manifest_file_path): - return ['mvn', 'dependency:tree', '-B', '-DoutputType=text', '-f', manifest_file_path, - f'-DoutputFile={MAVEN_DEP_TREE_FILE_NAME}'] + return [ + 'mvn', + 'dependency:tree', + '-B', + '-DoutputType=text', + '-f', + manifest_file_path, + f'-DoutputFile={MAVEN_DEP_TREE_FILE_NAME}', + ] diff --git a/cycode/cli/helpers/sca_code_scanner.py b/cycode/cli/helpers/sca_code_scanner.py index e12342fd..cbd040c7 100644 --- a/cycode/cli/helpers/sca_code_scanner.py +++ b/cycode/cli/helpers/sca_code_scanner.py @@ -17,23 +17,29 @@ BUILD_GRADLE_DEP_TREE_TIMEOUT = 180 -def perform_pre_commit_range_scan_actions(path: str, from_commit_documents: List[Document], - from_commit_rev: str, to_commit_documents: List[Document], - to_commit_rev: str) -> None: +def perform_pre_commit_range_scan_actions( + path: str, + from_commit_documents: List[Document], + from_commit_rev: str, + to_commit_documents: List[Document], + to_commit_rev: str, +) -> None: repo = Repo(path) add_ecosystem_related_files_if_exists(from_commit_documents, repo, from_commit_rev) add_ecosystem_related_files_if_exists(to_commit_documents, repo, to_commit_rev) -def perform_pre_hook_range_scan_actions(git_head_documents: List[Document], - pre_committed_documents: List[Document]) -> None: +def perform_pre_hook_range_scan_actions( + git_head_documents: List[Document], pre_committed_documents: List[Document] +) -> None: repo = Repo(os.getcwd()) add_ecosystem_related_files_if_exists(git_head_documents, repo, GIT_HEAD_COMMIT_REV) add_ecosystem_related_files_if_exists(pre_committed_documents) -def add_ecosystem_related_files_if_exists(documents: List[Document], repo: Optional[Repo] = None, - commit_rev: Optional[str] = None): +def add_ecosystem_related_files_if_exists( + documents: List[Document], repo: Optional[Repo] = None, commit_rev: Optional[str] = None +): for doc in documents: ecosystem = get_project_file_ecosystem(doc) if ecosystem is None: @@ -43,14 +49,18 @@ def add_ecosystem_related_files_if_exists(documents: List[Document], repo: Optio documents.extend(documents_to_add) -def get_doc_ecosystem_related_project_files(doc: Document, documents: List[Document], ecosystem: str, - commit_rev: Optional[str], repo: Optional[Repo]) -> List[Document]: +def get_doc_ecosystem_related_project_files( + doc: Document, documents: List[Document], ecosystem: str, commit_rev: Optional[str], repo: Optional[Repo] +) -> List[Document]: documents_to_add: List[Document] = [] for ecosystem_project_file in PROJECT_FILES_BY_ECOSYSTEM_MAP.get(ecosystem): file_to_search = join_paths(get_file_dir(doc.path), ecosystem_project_file) if not is_project_file_exists_in_documents(documents, file_to_search): - file_content = get_file_content_from_commit(repo, commit_rev, file_to_search) if repo \ + file_content = ( + get_file_content_from_commit(repo, commit_rev, file_to_search) + if repo else get_file_content(file_to_search) + ) if file_content is not None: documents_to_add.append(Document(file_to_search, file_content)) @@ -70,18 +80,17 @@ def get_project_file_ecosystem(document: Document) -> Optional[str]: return None -def try_restore_dependencies(context: click.Context, documents_to_add: List[Document], restore_dependencies, - document: Document): +def try_restore_dependencies( + context: click.Context, documents_to_add: List[Document], restore_dependencies, document: Document +): if restore_dependencies.is_project(document): restore_dependencies_document = restore_dependencies.restore(document) if restore_dependencies_document is None: - logger.warning('Error occurred while trying to generate dependencies tree. %s', - {'filename': document.path}) + logger.warning('Error occurred while trying to generate dependencies tree. %s', {'filename': document.path}) return if restore_dependencies_document.content is None: - logger.warning('Error occurred while trying to generate dependencies tree. %s', - {'filename': document.path}) + logger.warning('Error occurred while trying to generate dependencies tree. %s', {'filename': document.path}) restore_dependencies_document.content = '' else: is_monitor_action = context.obj.get('monitor') @@ -95,8 +104,9 @@ def try_restore_dependencies(context: click.Context, documents_to_add: List[Docu documents_to_add[restore_dependencies_document.path] = restore_dependencies_document -def add_dependencies_tree_document(context: click.Context, documents_to_scan: List[Document], - is_git_diff: bool = False) -> None: +def add_dependencies_tree_document( + context: click.Context, documents_to_scan: List[Document], is_git_diff: bool = False +) -> None: documents_to_add: Dict[str, Document] = {} restore_dependencies_list = restore_handlers(context, is_git_diff) @@ -110,7 +120,7 @@ def add_dependencies_tree_document(context: click.Context, documents_to_scan: Li def restore_handlers(context, is_git_diff): return [ RestoreGradleDependencies(context, is_git_diff, BUILD_GRADLE_DEP_TREE_TIMEOUT), - RestoreMavenDependencies(context, is_git_diff, BUILD_GRADLE_DEP_TREE_TIMEOUT) + RestoreMavenDependencies(context, is_git_diff, BUILD_GRADLE_DEP_TREE_TIMEOUT), ] diff --git a/cycode/cli/main.py b/cycode/cli/main.py index 4e52edb3..946c1c8b 100644 --- a/cycode/cli/main.py +++ b/cycode/cli/main.py @@ -34,71 +34,100 @@ "commit_history": code_scanner.scan_repository_commit_history, "path": code_scanner.scan_path, "pre_commit": code_scanner.pre_commit_scan, - "pre_receive": code_scanner.pre_receive_scan + "pre_receive": code_scanner.pre_receive_scan, }, ) -@click.option('--scan-type', '-t', default="secret", - help=""" +@click.option( + '--scan-type', + '-t', + default="secret", + help=""" \b Specify the scan you wish to execute (secret/iac/sca), the default is secret """, - type=click.Choice(config['scans']['supported_scans'])) -@click.option('--secret', - default=None, - help='Specify a Cycode client secret for this specific scan execution', - type=str, - required=False) -@click.option('--client-id', - default=None, - help='Specify a Cycode client ID for this specific scan execution', - type=str, - required=False) -@click.option('--show-secret', - is_flag=True, - default=False, - help='Show secrets in plain text', - type=bool, - required=False) -@click.option('--soft-fail', - is_flag=True, - default=False, - help='Run scan without failing, always return a non-error status code', - type=bool, - required=False) -@click.option('--output', default=None, - help=""" + type=click.Choice(config['scans']['supported_scans']), +) +@click.option( + '--secret', + default=None, + help='Specify a Cycode client secret for this specific scan execution', + type=str, + required=False, +) +@click.option( + '--client-id', + default=None, + help='Specify a Cycode client ID for this specific scan execution', + type=str, + required=False, +) +@click.option( + '--show-secret', is_flag=True, default=False, help='Show secrets in plain text', type=bool, required=False +) +@click.option( + '--soft-fail', + is_flag=True, + default=False, + help='Run scan without failing, always return a non-error status code', + type=bool, + required=False, +) +@click.option( + '--output', + default=None, + help=""" \b Specify the results output (text/json/table), the default is text """, - type=click.Choice(['text', 'json', 'table'])) -@click.option('--severity-threshold', - default=None, - help='Show only violations at the specified level or higher (supported for SCA scan type only).', - type=click.Choice([e.name for e in Severity]), - required=False) -@click.option('--sca-scan', - default=None, - help="Specify the sca scan you wish to execute (package-vulnerabilities/license-compliance), the default is both", - multiple=True, - type=click.Choice(config['scans']['supported_sca_scans'])) -@click.option('--monitor', - is_flag=True, - default=False, - help="When specified, the scan results will be recorded in the knowledge graph. Please note that when working in 'monitor' mode, the knowledge graph will not be updated as a result of SCM events (Push, Repo creation).(supported for SCA scan type only).", - type=bool, - required=False) -@click.option('--report', - is_flag=True, - default=False, - help="When specified, a violations report will be generated. A URL link to the report will be printed as an output to the command execution", - type=bool, - required=False) + type=click.Choice(['text', 'json', 'table']), +) +@click.option( + '--severity-threshold', + default=None, + help='Show only violations at the specified level or higher (supported for SCA scan type only).', + type=click.Choice([e.name for e in Severity]), + required=False, +) +@click.option( + '--sca-scan', + default=None, + help="Specify the sca scan you wish to execute (package-vulnerabilities/license-compliance), the default is both", + multiple=True, + type=click.Choice(config['scans']['supported_sca_scans']), +) +@click.option( + '--monitor', + is_flag=True, + default=False, + help="When specified, the scan results will be recorded in the knowledge graph. Please note that when working in 'monitor' mode, the knowledge graph will not be updated as a result of SCM events (Push, Repo creation).(supported for SCA scan type only).", + type=bool, + required=False, +) +@click.option( + '--report', + is_flag=True, + default=False, + help="When specified, a violations report will be generated. A URL link to the report will be printed as an output to the command execution", + type=bool, + required=False, +) @click.pass_context -def code_scan(context: click.Context, scan_type, client_id, secret, show_secret, soft_fail, output, severity_threshold, - sca_scan: List[str], monitor, report): - """ Scan content for secrets/IaC/sca/SAST violations, You need to specify which scan type: ci/commit_history/path/repository/etc """ +def code_scan( + context: click.Context, + scan_type, + client_id, + secret, + show_secret, + soft_fail, + output, + severity_threshold, + sca_scan: List[str], + monitor, + report, +): + """Scan content for secrets/IaC/sca/SAST violations, You need to specify which scan type: ci/commit_history/path/repository/etc""" if show_secret: context.obj["show_secret"] = show_secret else: @@ -148,25 +177,27 @@ def finalize(context: click.Context, *_, **__): @click.group( - commands={ - "scan": code_scan, - "configure": set_credentials, - "ignore": add_exclusions, - "auth": authenticate - }, - context_settings=CONTEXT + commands={"scan": code_scan, "configure": set_credentials, "ignore": add_exclusions, "auth": authenticate}, + context_settings=CONTEXT, ) @click.option( - "--verbose", "-v", is_flag=True, default=False, help="Show detailed logs", + "--verbose", + "-v", + is_flag=True, + default=False, + help="Show detailed logs", ) @click.option( - '--no-progress-meter', is_flag=True, default=False, help='Do not show the progress meter', + '--no-progress-meter', + is_flag=True, + default=False, + help='Do not show the progress meter', ) @click.option( '--output', default='text', help='Specify the output (text/json/table), the default is text', - type=click.Choice(['text', 'json', 'table']) + type=click.Choice(['text', 'json', 'table']), ) @click.option( '--user-agent', diff --git a/cycode/cli/models.py b/cycode/cli/models.py index 94218175..051b2fa7 100644 --- a/cycode/cli/models.py +++ b/cycode/cli/models.py @@ -12,10 +12,7 @@ def __init__(self, path: str, content: str, is_git_diff_format: bool = False, un self.unique_id = unique_id def __repr__(self) -> str: - return ( - "path:{0}, " - "content:{1}".format(self.path, self.content) - ) + return "path:{0}, " "content:{1}".format(self.path, self.content) class DocumentDetections: @@ -24,10 +21,7 @@ def __init__(self, document: Document, detections: List[Detection]): self.detections = detections def __repr__(self) -> str: - return ( - "document:{0}, " - "detections:{1}".format(self.document, self.detections) - ) + return "document:{0}, " "detections:{1}".format(self.document, self.detections) class Severity(Enum): diff --git a/cycode/cli/printers/json_printer.py b/cycode/cli/printers/json_printer.py index c02eff5b..6469100c 100644 --- a/cycode/cli/printers/json_printer.py +++ b/cycode/cli/printers/json_printer.py @@ -13,18 +13,12 @@ class JsonPrinter(BasePrinter): def print_result(self, result: CliResult) -> None: - result = { - 'result': result.success, - 'message': result.message - } + result = {'result': result.success, 'message': result.message} click.secho(self.get_data_json(result)) def print_error(self, error: CliError) -> None: - result = { - 'error': error.code, - 'message': error.message - } + result = {'error': error.code, 'message': error.message} click.secho(self.get_data_json(result)) @@ -40,8 +34,8 @@ def print_scan_results(self, local_scan_results: List['LocalScanResult']) -> Non def _get_json_scan_result(self, detections: dict) -> str: result = { - 'scan_id': 'DEPRECATED', # FIXME(MarshalX): we need change JSON struct to support multiple scan results - 'detections': detections + 'scan_id': 'DEPRECATED', # FIXME(MarshalX): we need change JSON struct to support multiple scan results + 'detections': detections, } return self.get_data_json(result) diff --git a/cycode/cli/printers/sca_table_printer.py b/cycode/cli/printers/sca_table_printer.py index 051a2e56..a8372873 100644 --- a/cycode/cli/printers/sca_table_printer.py +++ b/cycode/cli/printers/sca_table_printer.py @@ -35,7 +35,7 @@ def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: @staticmethod def _extract_detections_per_detection_type_id( - local_scan_results: List['LocalScanResult'] + local_scan_results: List['LocalScanResult'], ) -> Dict[str, List[Detection]]: detections_per_detection_type_id = defaultdict(list) @@ -47,7 +47,7 @@ def _extract_detections_per_detection_type_id( return detections_per_detection_type_id def _print_detection_per_detection_type_id( - self, detections_per_detection_type_id: Dict[str, List[Detection]] + self, detections_per_detection_type_id: Dict[str, List[Detection]] ) -> None: for detection_type_id in detections_per_detection_type_id: detections = detections_per_detection_type_id[detection_type_id] @@ -84,9 +84,7 @@ def _get_table_headers(self) -> list: return [] - def _print_table_detections( - self, detections: List[Detection], headers: List[str], rows, title: str - ) -> None: + def _print_table_detections(self, detections: List[Detection], headers: List[str], rows, title: str) -> None: self._print_summary_issues(detections, title) text_table = Texttable() text_table.header(headers) @@ -127,7 +125,7 @@ def _get_common_detection_fields(self, detection: Detection) -> List[str]: detection.detection_details.get('package_name'), detection.detection_details.get('is_direct_dependency_str'), detection.detection_details.get('is_dev_dependency_str'), - dependency_paths + dependency_paths, ] if self._is_git_repository(): @@ -140,7 +138,7 @@ def _get_upgrade_package_vulnerability(self, detection: Detection) -> List[str]: row = [ detection.detection_details.get('advisory_severity'), *self._get_common_detection_fields(detection), - detection.detection_details.get('vulnerability_id') + detection.detection_details.get('vulnerability_id'), ] upgrade = '' diff --git a/cycode/cli/printers/table_printer.py b/cycode/cli/printers/table_printer.py index 12f8f61b..3e5e6d49 100644 --- a/cycode/cli/printers/table_printer.py +++ b/cycode/cli/printers/table_printer.py @@ -91,7 +91,7 @@ def _enrich_table_with_values(self, table: Table, detection: Detection, document self._enrich_table_with_detection_code_segment_values(table, detection, document) def _enrich_table_with_detection_summary_values( - self, table: Table, detection: Detection, document: Document + self, table: Table, detection: Detection, document: Document ) -> None: issue_type = detection.message if self.scan_type == SECRET_SCAN_TYPE: @@ -104,7 +104,7 @@ def _enrich_table_with_detection_summary_values( table.set(COMMIT_SHA_COLUMN, detection.detection_details.get('commit_id', '')) def _enrich_table_with_detection_code_segment_values( - self, table: Table, detection: Detection, document: Document + self, table: Table, detection: Detection, document: Document ) -> None: detection_details = detection.detection_details @@ -119,7 +119,7 @@ def _enrich_table_with_detection_code_segment_values( file_content_lines = document.content.splitlines() if detection_line < len(file_content_lines): line = file_content_lines[detection_line] - violation = line[detection_column: detection_column + violation_length] + violation = line[detection_column : detection_column + violation_length] if not self.show_secret: violation = obfuscate_text(violation) diff --git a/cycode/cli/printers/text_printer.py b/cycode/cli/printers/text_printer.py index 2d25af2f..de71371d 100644 --- a/cycode/cli/printers/text_printer.py +++ b/cycode/cli/printers/text_printer.py @@ -42,7 +42,7 @@ def print_scan_results(self, local_scan_results: List['LocalScanResult']): ) def _print_document_detections( - self, document_detections: DocumentDetections, scan_id: str, report_url: Optional[str] + self, document_detections: DocumentDetections, scan_id: str, report_url: Optional[str] ): document = document_detections.document lines_to_display = self._get_lines_to_display_count() @@ -51,7 +51,7 @@ def _print_document_detections( self._print_detection_code_segment(detection, document, lines_to_display) def _print_detection_summary( - self, detection: Detection, document_path: str, scan_id: str, report_url: Optional[str] + self, detection: Detection, document_path: str, scan_id: str, report_url: Optional[str] ): detection_name = detection.type if self.scan_type == SECRET_SCAN_TYPE else detection.message @@ -81,15 +81,23 @@ def _get_code_segment_start_line(self, detection_line: int, code_segment_size: i start_line = detection_line - math.ceil(code_segment_size / 2) return 0 if start_line < 0 else start_line - def _print_line_of_code_segment(self, document: Document, line: str, line_number: int, - detection_position_in_line: int, violation_length: int, is_detection_line: bool): + def _print_line_of_code_segment( + self, + document: Document, + line: str, + line_number: int, + detection_position_in_line: int, + violation_length: int, + is_detection_line: bool, + ): if is_detection_line: self._print_detection_line(document, line, line_number, detection_position_in_line, violation_length) else: self._print_line(document, line, line_number) - def _print_detection_line(self, document: Document, line: str, line_number: int, detection_position_in_line: int, - violation_length: int) -> None: + def _print_detection_line( + self, document: Document, line: str, line_number: int, detection_position_in_line: int, violation_length: int + ) -> None: click.echo( f'{self._get_line_number_style(line_number)} ' f'{self._get_detection_line_style(line, document.is_git_diff_format, detection_position_in_line, violation_length)}' @@ -106,19 +114,21 @@ def _get_detection_line_style(self, line: str, is_git_diff: bool, start_position if self.scan_type != SECRET_SCAN_TYPE or start_position < 0 or length < 0: return self._get_line_style(line, is_git_diff, line_color) - violation = line[start_position: start_position + length] + violation = line[start_position : start_position + length] if not self.show_secret: violation = obfuscate_text(violation) - line_to_violation = line[0: start_position] - line_from_violation = line[start_position + length:] + line_to_violation = line[0:start_position] + line_from_violation = line[start_position + length :] - return f'{self._get_line_style(line_to_violation, is_git_diff, line_color)}' \ - f'{self._get_line_style(violation, is_git_diff, line_color, underline=True)}' \ - f'{self._get_line_style(line_from_violation, is_git_diff, line_color)}' + return ( + f'{self._get_line_style(line_to_violation, is_git_diff, line_color)}' + f'{self._get_line_style(violation, is_git_diff, line_color, underline=True)}' + f'{self._get_line_style(line_from_violation, is_git_diff, line_color)}' + ) def _get_line_style( - self, line: str, is_git_diff: bool, color: Optional[str] = None, underline: bool = False + self, line: str, is_git_diff: bool, color: Optional[str] = None, underline: bool = False ) -> str: if color is None: color = self._get_line_color(line, is_git_diff) @@ -138,13 +148,16 @@ def _get_line_color(self, line: str, is_git_diff: bool) -> str: return self.WHITE_COLOR_NAME def _get_line_number_style(self, line_number: int): - return f'{click.style(str(line_number), fg=self.WHITE_COLOR_NAME, bold=False)} ' \ - f'{click.style("|", fg=self.RED_COLOR_NAME, bold=False)}' + return ( + f'{click.style(str(line_number), fg=self.WHITE_COLOR_NAME, bold=False)} ' + f'{click.style("|", fg=self.RED_COLOR_NAME, bold=False)}' + ) def _get_lines_to_display_count(self) -> int: result_printer_configuration = config.get('result_printer') - lines_to_display_of_scan = result_printer_configuration.get(self.scan_type, {}) \ - .get(self.command_scan_type, {}).get('lines_to_display') + lines_to_display_of_scan = ( + result_printer_configuration.get(self.scan_type, {}).get(self.command_scan_type, {}).get('lines_to_display') + ) if lines_to_display_of_scan: return lines_to_display_of_scan @@ -152,8 +165,11 @@ def _get_lines_to_display_count(self) -> int: def _print_detection_from_file(self, detection: Detection, document: Document, code_segment_size: int): detection_details = detection.detection_details - detection_line = detection_details.get('line', -1) if self.scan_type == SECRET_SCAN_TYPE else \ - detection_details.get('line_in_file', -1) + detection_line = ( + detection_details.get('line', -1) + if self.scan_type == SECRET_SCAN_TYPE + else detection_details.get('line_in_file', -1) + ) detection_position = detection_details.get('start_position', -1) violation_length = detection_details.get('length', -1) @@ -170,8 +186,14 @@ def _print_detection_from_file(self, detection: Detection, document: Document, c current_line = file_lines[current_line_index] is_detection_line = current_line_index == detection_line - self._print_line_of_code_segment(document, current_line, current_line_index + 1, detection_position_in_line, - violation_length, is_detection_line) + self._print_line_of_code_segment( + document, + current_line, + current_line_index + 1, + detection_position_in_line, + violation_length, + is_detection_line, + ) click.echo() def _print_detection_from_git_diff(self, detection: Detection, document: Document): @@ -187,8 +209,13 @@ def _print_detection_from_git_diff(self, detection: Detection, document: Documen detection_position_in_line = get_position_in_line(git_diff_content, detection_position) click.echo() - self._print_detection_line(document, detection_line, detection_line_number_in_original_file, - detection_position_in_line, violation_length) + self._print_detection_line( + document, + detection_line, + detection_line_number_in_original_file, + detection_position_in_line, + violation_length, + ) click.echo() def _is_git_diff_based_scan(self): diff --git a/cycode/cli/user_settings/base_file_manager.py b/cycode/cli/user_settings/base_file_manager.py index 22c4fffc..ec7c4813 100644 --- a/cycode/cli/user_settings/base_file_manager.py +++ b/cycode/cli/user_settings/base_file_manager.py @@ -4,7 +4,6 @@ class BaseFileManager(ABC): - @abstractmethod def get_filename(self): pass diff --git a/cycode/cli/user_settings/config_file_manager.py b/cycode/cli/user_settings/config_file_manager.py index 7c99f033..876f85f1 100644 --- a/cycode/cli/user_settings/config_file_manager.py +++ b/cycode/cli/user_settings/config_file_manager.py @@ -46,26 +46,19 @@ def get_command_timeout(self, command_scan_type) -> Optional[int]: return self._get_value_from_command_scan_type_configuration(command_scan_type, self.COMMAND_TIMEOUT_FIELD_NAME) def get_exclude_detections_in_deleted_lines(self, command_scan_type) -> Optional[bool]: - return self._get_value_from_command_scan_type_configuration(command_scan_type, - self.EXCLUDE_DETECTIONS_IN_DELETED_LINES) + return self._get_value_from_command_scan_type_configuration( + command_scan_type, self.EXCLUDE_DETECTIONS_IN_DELETED_LINES + ) def update_base_url(self, base_url: str): - update_data = { - self.ENVIRONMENT_SECTION_NAME: { - self.API_URL_FIELD_NAME: base_url - } - } + update_data = {self.ENVIRONMENT_SECTION_NAME: {self.API_URL_FIELD_NAME: base_url}} self.write_content_to_file(update_data) def get_installation_id(self) -> Optional[str]: return self._get_value_from_environment_section(self.INSTALLATION_ID_FIELD_NAME) def update_installation_id(self, installation_id: str) -> None: - update_data = { - self.ENVIRONMENT_SECTION_NAME: { - self.INSTALLATION_ID_FIELD_NAME: installation_id - } - } + update_data = {self.ENVIRONMENT_SECTION_NAME: {self.INSTALLATION_ID_FIELD_NAME: installation_id}} self.write_content_to_file(update_data) def add_exclusion(self, scan_type, exclusion_type, new_exclusion): @@ -75,13 +68,7 @@ def add_exclusion(self, scan_type, exclusion_type, new_exclusion): exclusions.append(new_exclusion) - update_data = { - self.EXCLUSIONS_SECTION_NAME: { - scan_type: { - exclusion_type: exclusions - } - } - } + update_data = {self.EXCLUSIONS_SECTION_NAME: {scan_type: {exclusion_type: exclusions}}} self.write_content_to_file(update_data) def get_config_directory_path(self) -> str: diff --git a/cycode/cli/user_settings/configuration_manager.py b/cycode/cli/user_settings/configuration_manager.py index 3769a77f..fa11c87a 100644 --- a/cycode/cli/user_settings/configuration_manager.py +++ b/cycode/cli/user_settings/configuration_manager.py @@ -95,12 +95,18 @@ def get_config_file_manager(self, scope: Optional[str] = None) -> ConfigFileMana return self.global_config_file_manager def get_scan_polling_timeout_in_seconds(self) -> int: - return int(self._get_value_from_environment_variables(SCAN_POLLING_TIMEOUT_IN_SECONDS_ENV_VAR_NAME, - DEFAULT_SCAN_POLLING_TIMEOUT_IN_SECONDS)) + return int( + self._get_value_from_environment_variables( + SCAN_POLLING_TIMEOUT_IN_SECONDS_ENV_VAR_NAME, DEFAULT_SCAN_POLLING_TIMEOUT_IN_SECONDS + ) + ) def get_sca_pre_commit_timeout_in_seconds(self) -> int: - return int(self._get_value_from_environment_variables(SCA_PRE_COMMIT_TIMEOUT_IN_SECONDS_ENV_VAR_NAME, - DEFAULT_SCA_PRE_COMMIT_TIMEOUT_IN_SECONDS)) + return int( + self._get_value_from_environment_variables( + SCA_PRE_COMMIT_TIMEOUT_IN_SECONDS_ENV_VAR_NAME, DEFAULT_SCA_PRE_COMMIT_TIMEOUT_IN_SECONDS + ) + ) def get_pre_receive_max_commits_to_scan_count(self, command_scan_type: str) -> int: max_commits = self._get_value_from_environment_variables(PRE_RECEIVE_MAX_COMMITS_TO_SCAN_COUNT_ENV_VAR_NAME) @@ -134,17 +140,20 @@ def get_pre_receive_command_timeout(self, command_scan_type: str) -> int: def get_should_exclude_detections_in_deleted_lines(self, command_scan_type: str) -> bool: exclude_detections_in_deleted_lines = self._get_value_from_environment_variables( - EXCLUDE_DETECTIONS_IN_DELETED_LINES_ENV_VAR_NAME) + EXCLUDE_DETECTIONS_IN_DELETED_LINES_ENV_VAR_NAME + ) if exclude_detections_in_deleted_lines is not None: return exclude_detections_in_deleted_lines.lower() in ('true', '1') - exclude_detections_in_deleted_lines = self.local_config_file_manager \ - .get_exclude_detections_in_deleted_lines(command_scan_type) + exclude_detections_in_deleted_lines = self.local_config_file_manager.get_exclude_detections_in_deleted_lines( + command_scan_type + ) if exclude_detections_in_deleted_lines is not None: return exclude_detections_in_deleted_lines - exclude_detections_in_deleted_lines = self.global_config_file_manager\ - .get_exclude_detections_in_deleted_lines(command_scan_type) + exclude_detections_in_deleted_lines = self.global_config_file_manager.get_exclude_detections_in_deleted_lines( + command_scan_type + ) if exclude_detections_in_deleted_lines is not None: return exclude_detections_in_deleted_lines diff --git a/cycode/cli/user_settings/credentials_manager.py b/cycode/cli/user_settings/credentials_manager.py index 67d31275..675deae2 100644 --- a/cycode/cli/user_settings/credentials_manager.py +++ b/cycode/cli/user_settings/credentials_manager.py @@ -7,7 +7,6 @@ class CredentialsManager(BaseFileManager): - HOME_PATH: str = Path.home() CYCODE_HIDDEN_DIRECTORY: str = '.cycode' FILE_NAME: str = 'credentials.yaml' @@ -38,10 +37,7 @@ def get_credentials_from_file(self) -> (str, str): return client_id, client_secret def update_credentials_file(self, client_id: str, client_secret: str): - credentials = { - self.CLIENT_ID_FIELD_NAME: client_id, - self.CLIENT_SECRET_FIELD_NAME: client_secret - } + credentials = {self.CLIENT_ID_FIELD_NAME: client_id, self.CLIENT_SECRET_FIELD_NAME: client_secret} filename = self.get_filename() self.write_content_to_file(credentials) diff --git a/cycode/cli/user_settings/user_settings_commands.py b/cycode/cli/user_settings/user_settings_commands.py index 24ebb0e9..a7863671 100644 --- a/cycode/cli/user_settings/user_settings_commands.py +++ b/cycode/cli/user_settings/user_settings_commands.py @@ -12,16 +12,18 @@ from cycode.cyclient import logger CREDENTIALS_UPDATED_SUCCESSFULLY_MESSAGE = 'Successfully configured CLI credentials!' -CREDENTIALS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE = 'Note that the credentials that already exist in environment' \ - ' variables (CYCODE_CLIENT_ID and CYCODE_CLIENT_SECRET) take' \ - ' precedent over these credentials; either update or remove ' \ - 'the environment variables.' +CREDENTIALS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE = ( + 'Note that the credentials that already exist in environment' + ' variables (CYCODE_CLIENT_ID and CYCODE_CLIENT_SECRET) take' + ' precedent over these credentials; either update or remove ' + 'the environment variables.' +) credentials_manager = CredentialsManager() @click.command() def set_credentials(): - """ Initial command to authenticate your CLI client with Cycode using client ID and client secret """ + """Initial command to authenticate your CLI client with Cycode using client ID and client secret""" click.echo(f'Update credentials in file ({credentials_manager.get_filename()})') current_client_id, current_client_secret = credentials_manager.get_credentials_from_file() client_id = _get_client_id_input(current_client_id) @@ -35,28 +37,55 @@ def set_credentials(): @click.command() -@click.option("--by-value", type=click.STRING, required=False, - help="Ignore a specific value while scanning for secrets") -@click.option("--by-sha", type=click.STRING, required=False, - help='Ignore a specific SHA512 representation of a string while scanning for secrets') -@click.option("--by-path", type=click.STRING, required=False, - help='Avoid scanning a specific path. Need to specify scan type ') -@click.option("--by-rule", type=click.STRING, required=False, - help='Ignore scanning a specific secret rule ID/IaC rule ID. Need to specify scan type.') -@click.option("--by-package", type=click.STRING, required=False, - help='Ignore scanning a specific package version while running SCA scan. expected pattern - name@version') -@click.option('--scan-type', '-t', default='secret', - help=""" +@click.option( + "--by-value", type=click.STRING, required=False, help="Ignore a specific value while scanning for secrets" +) +@click.option( + "--by-sha", + type=click.STRING, + required=False, + help='Ignore a specific SHA512 representation of a string while scanning for secrets', +) +@click.option( + "--by-path", type=click.STRING, required=False, help='Avoid scanning a specific path. Need to specify scan type ' +) +@click.option( + "--by-rule", + type=click.STRING, + required=False, + help='Ignore scanning a specific secret rule ID/IaC rule ID. Need to specify scan type.', +) +@click.option( + "--by-package", + type=click.STRING, + required=False, + help='Ignore scanning a specific package version while running SCA scan. expected pattern - name@version', +) +@click.option( + '--scan-type', + '-t', + default='secret', + help=""" \b Specify the scan you wish to execute (secrets/iac), the default is secrets """, - type=click.Choice(config['scans']['supported_scans']), required=False) -@click.option('--global', '-g', 'is_global', is_flag=True, default=False, required=False, - help='Add an ignore rule and update it in the global .cycode config file') -def add_exclusions(by_value: str, by_sha: str, by_path: str, by_rule: str, by_package: str, scan_type: str, - is_global: bool): - """ Ignore a specific value, path or rule ID """ + type=click.Choice(config['scans']['supported_scans']), + required=False, +) +@click.option( + '--global', + '-g', + 'is_global', + is_flag=True, + default=False, + required=False, + help='Add an ignore rule and update it in the global .cycode config file', +) +def add_exclusions( + by_value: str, by_sha: str, by_path: str, by_rule: str, by_package: str, scan_type: str, is_global: bool +): + """Ignore a specific value, path or rule ID""" 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") @@ -88,24 +117,29 @@ def add_exclusions(by_value: str, by_sha: str, by_path: str, by_rule: str, by_pa exclusion_value = by_rule configuration_scope = 'global' if is_global else 'local' - logger.debug('Adding ignore rule, %s', - {'configuration_scope': configuration_scope, 'exclusion_type': exclusion_type, - 'exclusion_value': exclusion_value}) + logger.debug( + 'Adding ignore rule, %s', + { + 'configuration_scope': configuration_scope, + 'exclusion_type': exclusion_type, + 'exclusion_value': exclusion_value, + }, + ) configuration_manager.add_exclusion(configuration_scope, scan_type, exclusion_type, exclusion_value) def _get_client_id_input(current_client_id: str) -> str: - new_client_id = click.prompt(f'cycode client id [{_obfuscate_credential(current_client_id)}]', - default='', - show_default=False) + new_client_id = click.prompt( + f'cycode client id [{_obfuscate_credential(current_client_id)}]', default='', show_default=False + ) return current_client_id if not new_client_id else new_client_id def _get_client_secret_input(current_client_secret: str) -> str: - new_client_secret = click.prompt(f'cycode client secret [{_obfuscate_credential(current_client_secret)}]', - default='', - show_default=False) + new_client_secret = click.prompt( + f'cycode client secret [{_obfuscate_credential(current_client_secret)}]', default='', show_default=False + ) return current_client_secret if not new_client_secret else new_client_secret @@ -121,8 +155,9 @@ def _are_credentials_exist_in_environment_variables(): return client_id is not None or client_secret is not None -def _should_update_credentials(current_client_id: str, current_client_secret: str, new_client_id: str, - new_client_secret: str) -> bool: +def _should_update_credentials( + current_client_id: str, current_client_secret: str, new_client_id: str, new_client_secret: str +) -> bool: return current_client_id != new_client_id or current_client_secret != new_client_secret diff --git a/cycode/cli/utils/progress_bar.py b/cycode/cli/utils/progress_bar.py index 196ca9e9..010405ef 100644 --- a/cycode/cli/utils/progress_bar.py +++ b/cycode/cli/utils/progress_bar.py @@ -179,9 +179,7 @@ def _maybe_update_current_section(self) -> None: cur_val = self._section_values.get(self._current_section.section, 0) if cur_val >= max_val: next_section = _PROGRESS_BAR_SECTIONS[self._current_section.section.next()] - logger.debug( - f'_update_current_section: {self._current_section.section} -> {next_section.section}' - ) + logger.debug(f'_update_current_section: {self._current_section.section} -> {next_section.section}') self._current_section = next_section self._current_section_value = 0 diff --git a/cycode/cli/utils/scan_batch.py b/cycode/cli/utils/scan_batch.py index 5604d562..b5f1c5d2 100644 --- a/cycode/cli/utils/scan_batch.py +++ b/cycode/cli/utils/scan_batch.py @@ -2,8 +2,12 @@ from multiprocessing.pool import ThreadPool from typing import List, TYPE_CHECKING, Callable, Tuple, Dict -from cycode.cli.consts import SCAN_BATCH_MAX_SIZE_IN_BYTES, SCAN_BATCH_MAX_FILES_COUNT, SCAN_BATCH_SCANS_PER_CPU, \ - SCAN_BATCH_MAX_PARALLEL_SCANS +from cycode.cli.consts import ( + SCAN_BATCH_MAX_SIZE_IN_BYTES, + SCAN_BATCH_MAX_FILES_COUNT, + SCAN_BATCH_SCANS_PER_CPU, + SCAN_BATCH_MAX_PARALLEL_SCANS, +) from cycode.cli.models import Document from cycode.cli.utils.progress_bar import ProgressBarSection @@ -13,9 +17,9 @@ def split_documents_into_batches( - documents: List[Document], - max_size_mb: int = SCAN_BATCH_MAX_SIZE_IN_BYTES, - max_files_count: int = SCAN_BATCH_MAX_FILES_COUNT, + documents: List[Document], + max_size_mb: int = SCAN_BATCH_MAX_SIZE_IN_BYTES, + max_files_count: int = SCAN_BATCH_MAX_FILES_COUNT, ) -> List[List[Document]]: batches = [] @@ -44,11 +48,11 @@ def _get_threads_count() -> int: def run_parallel_batched_scan( - scan_function: Callable[[List[Document]], Tuple[str, 'CliError', 'LocalScanResult']], - documents: List[Document], - progress_bar: 'BaseProgressBar', - max_size_mb: int = SCAN_BATCH_MAX_SIZE_IN_BYTES, - max_files_count: int = SCAN_BATCH_MAX_FILES_COUNT, + scan_function: Callable[[List[Document]], Tuple[str, 'CliError', 'LocalScanResult']], + documents: List[Document], + progress_bar: 'BaseProgressBar', + max_size_mb: int = SCAN_BATCH_MAX_SIZE_IN_BYTES, + max_files_count: int = SCAN_BATCH_MAX_FILES_COUNT, ) -> Tuple[Dict[str, 'CliError'], List['LocalScanResult']]: batches = split_documents_into_batches(documents, max_size_mb, max_files_count) progress_bar.set_section_length(ProgressBarSection.SCAN, len(batches)) # * 3 diff --git a/cycode/cli/utils/shell_executor.py b/cycode/cli/utils/shell_executor.py index ca2e8caa..512d7432 100644 --- a/cycode/cli/utils/shell_executor.py +++ b/cycode/cli/utils/shell_executor.py @@ -9,7 +9,7 @@ def shell( - command: Union[str, List[str]], timeout: int = _SUBPROCESS_DEFAULT_TIMEOUT_SEC, execute_in_shell=False + command: Union[str, List[str]], timeout: int = _SUBPROCESS_DEFAULT_TIMEOUT_SEC, execute_in_shell=False ) -> Optional[str]: logger.debug(f'Executing shell command: {command}') diff --git a/cycode/cli/utils/task_timer.py b/cycode/cli/utils/task_timer.py index dadc0aea..7d8efd9e 100644 --- a/cycode/cli/utils/task_timer.py +++ b/cycode/cli/utils/task_timer.py @@ -5,7 +5,6 @@ class FunctionContext: - def __init__(self, function: Callable, args: List = None, kwargs: Dict = None): self.function = function self.args = args or [] @@ -20,8 +19,8 @@ class TimerThread(Thread): timeout - the amount of time to count until timeout in seconds quit_function (Mandatory) - function to perform when reaching to timeout """ - def __init__(self, timeout: int, - quit_function: FunctionContext): + + def __init__(self, timeout: int, quit_function: FunctionContext): Thread.__init__(self) self._timeout = timeout self._quit_function = quit_function @@ -56,8 +55,8 @@ class TimeoutAfter: quit_function (Optional) - function to perform when reaching to timeout, the default option is to interrupt main thread """ - def __init__(self, timeout: int, - quit_function: Optional[FunctionContext] = None): + + def __init__(self, timeout: int, quit_function: Optional[FunctionContext] = None): self.timeout = timeout self._quit_function = quit_function or FunctionContext(function=self.timeout_function) self.timer = TimerThread(timeout, quit_function=self._quit_function) @@ -66,8 +65,9 @@ def __enter__(self) -> None: if self.timeout: self.timer.start() - def __exit__(self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType]) -> None: + def __exit__( + self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType] + ) -> None: if self.timeout: self.timer.stop() diff --git a/cycode/cli/zip_file.py b/cycode/cli/zip_file.py index 3a23f9a2..a177b6b7 100644 --- a/cycode/cli/zip_file.py +++ b/cycode/cli/zip_file.py @@ -28,6 +28,6 @@ def read(self) -> bytes: def concat_unique_id(filename: str, unique_id: str) -> str: if filename.startswith(os.sep): # remove leading slash to join path correctly - filename = filename[len(os.sep):] + filename = filename[len(os.sep) :] return os.path.join(unique_id, filename) diff --git a/cycode/cyclient/config.py b/cycode/cyclient/config.py index 5946265c..a73fc9d7 100644 --- a/cycode/cyclient/config.py +++ b/cycode/cyclient/config.py @@ -5,6 +5,7 @@ from cycode.cli.consts import * from cycode.cli.user_settings.configuration_manager import ConfigurationManager + # set io encoding (for windows) from .config_dev import DEV_MODE_ENV_VAR_NAME, DEV_TENANT_ID_ENV_VAR_NAME @@ -16,7 +17,7 @@ stream=sys.stdout, level=logging.DEBUG, format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", - datefmt="%Y-%m-%d %H:%M:%S" + datefmt="%Y-%m-%d %H:%M:%S", ) logging.getLogger("urllib3").setLevel(logging.WARNING) logging.getLogger("werkzeug").setLevel(logging.WARNING) @@ -32,7 +33,7 @@ TIMEOUT_ENV_VAR_NAME: 300, LOGGING_LEVEL_ENV_VAR_NAME: logging.INFO, DEV_MODE_ENV_VAR_NAME: 'False', - BATCH_SIZE_ENV_VAR_NAME: 20 + BATCH_SIZE_ENV_VAR_NAME: 20, } configuration = dict(DEFAULT_CONFIGURATION, **os.environ) diff --git a/cycode/cyclient/config_dev.py b/cycode/cyclient/config_dev.py index 1033c7e7..5913bb16 100644 --- a/cycode/cyclient/config_dev.py +++ b/cycode/cyclient/config_dev.py @@ -1,3 +1,3 @@ DEV_CYCODE_API_URL = "http://localhost" DEV_MODE_ENV_VAR_NAME = "DEV_MODE" -DEV_TENANT_ID_ENV_VAR_NAME = "DEV_TENANT_ID" \ No newline at end of file +DEV_TENANT_ID_ENV_VAR_NAME = "DEV_TENANT_ID" diff --git a/cycode/cyclient/cycode_client_base.py b/cycode/cyclient/cycode_client_base.py index dc37a258..e6220ac2 100644 --- a/cycode/cyclient/cycode_client_base.py +++ b/cycode/cyclient/cycode_client_base.py @@ -42,48 +42,24 @@ def reset_user_agent() -> None: def enrich_user_agent(user_agent_suffix: str) -> None: CycodeClientBase.MANDATORY_HEADERS['User-Agent'] += f' {user_agent_suffix}' - def post( - self, - url_path: str, - body: dict = None, - headers: dict = None, - **kwargs - ) -> Response: + def post(self, url_path: str, body: dict = None, headers: dict = None, **kwargs) -> Response: return self._execute(method='post', endpoint=url_path, json=body, headers=headers, **kwargs) - def put( - self, - url_path: str, - body: dict = None, - headers: dict = None, - **kwargs - ) -> Response: + def put(self, url_path: str, body: dict = None, headers: dict = None, **kwargs) -> Response: return self._execute(method='put', endpoint=url_path, json=body, headers=headers, **kwargs) - def get( - self, - url_path: str, - headers: dict = None, - **kwargs - ) -> Response: + def get(self, url_path: str, headers: dict = None, **kwargs) -> Response: return self._execute(method='get', endpoint=url_path, headers=headers, **kwargs) def _execute( - self, - method: str, - endpoint: str, - headers: dict = None, - without_auth: bool = False, - **kwargs + self, method: str, endpoint: str, headers: dict = None, without_auth: bool = False, **kwargs ) -> Response: url = self.build_full_url(self.api_url, endpoint) logger.debug(f'Executing {method.upper()} request to {url}') try: headers = self.get_request_headers(headers, without_auth=without_auth) - response = request( - method=method, url=url, timeout=self.timeout, headers=headers, **kwargs - ) + response = request(method=method, url=url, timeout=self.timeout, headers=headers, **kwargs) logger.debug(f'Response {response.status_code} from {url}. Content: {response.text}') diff --git a/cycode/cyclient/cycode_dev_based_client.py b/cycode/cyclient/cycode_dev_based_client.py index 651e0db4..31f182bf 100644 --- a/cycode/cyclient/cycode_dev_based_client.py +++ b/cycode/cyclient/cycode_dev_based_client.py @@ -7,7 +7,6 @@ class CycodeDevBasedClient(CycodeClientBase): - def __init__(self, api_url): super().__init__(api_url) diff --git a/cycode/cyclient/models.py b/cycode/cyclient/models.py index 9885896e..330fd2d9 100644 --- a/cycode/cyclient/models.py +++ b/cycode/cyclient/models.py @@ -4,8 +4,15 @@ class Detection(Schema): - def __init__(self, detection_type_id: str, type: str, message: str, detection_details: dict, - detection_rule_id: str, severity: Optional[str] = None): + def __init__( + self, + detection_type_id: str, + type: str, + message: str, + detection_details: dict, + detection_rule_id: str, + severity: Optional[str] = None, + ): super().__init__() self.message = message self.type = type @@ -15,11 +22,13 @@ def __init__(self, detection_type_id: str, type: str, message: str, detection_de self.detection_rule_id = detection_rule_id def __repr__(self) -> str: - return f'type:{self.type}, ' \ - f'severity:{self.severity}, ' \ - f'message:{self.message}, ' \ - f'detection_details:{repr(self.detection_details)}, ' \ - f'detection_rule_id:{self.detection_rule_id}' + return ( + f'type:{self.type}, ' + f'severity:{self.severity}, ' + f'message:{self.message}, ' + f'detection_details:{repr(self.detection_details)}, ' + f'detection_rule_id:{self.detection_rule_id}' + ) class DetectionSchema(Schema): @@ -61,8 +70,14 @@ def build_dto(self, data, **kwargs): class ZippedFileScanResult(Schema): - def __init__(self, did_detect: bool, detections_per_file: List[DetectionsPerFile], report_url: Optional[str] = None, - scan_id: str = None, err: str = None): + def __init__( + self, + did_detect: bool, + detections_per_file: List[DetectionsPerFile], + report_url: Optional[str] = None, + scan_id: str = None, + err: str = None, + ): super().__init__() self.did_detect = did_detect self.detections_per_file = detections_per_file @@ -78,8 +93,7 @@ class Meta: did_detect = fields.Boolean() scan_id = fields.String() report_url = fields.String(allow_none=True) - detections_per_file = fields.List( - fields.Nested(DetectionsPerFileSchema)) + detections_per_file = fields.List(fields.Nested(DetectionsPerFileSchema)) err = fields.String() @post_load @@ -102,8 +116,7 @@ class Meta: did_detect = fields.Boolean() scan_id = fields.String() - detections = fields.List( - fields.Nested(DetectionSchema), required=False, allow_none=True) + detections = fields.List(fields.Nested(DetectionSchema), required=False, allow_none=True) err = fields.String() @post_load @@ -131,8 +144,16 @@ def build_dto(self, data, **kwargs): class ScanDetailsResponse(Schema): - def __init__(self, id: str = None, scan_status: str = None, results_count: int = None, metadata: str = None, message: str = None, - scan_update_at: str = None, err: str = None): + def __init__( + self, + id: str = None, + scan_status: str = None, + results_count: int = None, + metadata: str = None, + message: str = None, + scan_update_at: str = None, + err: str = None, + ): super().__init__() self.id = id self.scan_status = scan_status @@ -287,9 +308,9 @@ def build_dto(self, data, **kwargs): class UserAgentOptionScheme(Schema): app_name = fields.String(required=True) # ex. vscode_extension - app_version = fields.String(required=True) # ex. 0.2.3 + app_version = fields.String(required=True) # ex. 0.2.3 env_name = fields.String(required=True) # ex.: Visual Studio Code - env_version = fields.String(required=True) # ex. 1.78.2 + env_version = fields.String(required=True) # ex. 1.78.2 @post_load def build_dto(self, data: dict, **_) -> 'UserAgentOption': @@ -309,8 +330,10 @@ def user_agent_suffix(self) -> str: Example: vscode_extension (AppVersion: 0.1.2; EnvName: vscode; EnvVersion: 1.78.2) """ - return f'{self.app_name} ' \ - f'(' \ - f'AppVersion: {self.app_version}; ' \ - f'EnvName: {self.env_name}; EnvVersion: {self.env_version}' \ - f')' + return ( + f'{self.app_name} ' + f'(' + f'AppVersion: {self.app_version}; ' + f'EnvName: {self.env_name}; EnvVersion: {self.env_version}' + f')' + ) diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index 6b754e4a..e6a46a3b 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -28,42 +28,51 @@ def file_scan(self, scan_type: str, path: str) -> models.ScanResult: response = self.scan_cycode_client.post(url_path=url_path, files=files) return self.parse_scan_response(response) - def zipped_file_scan(self, scan_type: str, zip_file: InMemoryZip, scan_id: str, scan_parameters: dict, - is_git_diff: bool = False) -> models.ZippedFileScanResult: + def zipped_file_scan( + self, scan_type: str, zip_file: InMemoryZip, scan_id: str, scan_parameters: dict, is_git_diff: bool = False + ) -> models.ZippedFileScanResult: url_path = f'{self.scan_config.get_service_name(scan_type)}/{self.SCAN_CONTROLLER_PATH}/zipped-file' files = {'file': ('multiple_files_scan.zip', zip_file.read())} response = self.scan_cycode_client.post( url_path=url_path, data={'scan_id': scan_id, 'is_git_diff': is_git_diff, 'scan_parameters': json.dumps(scan_parameters)}, - files=files + files=files, ) return self.parse_zipped_file_scan_response(response) - def zipped_file_scan_async(self, zip_file: InMemoryZip, scan_type: str, scan_parameters: dict, - is_git_diff: bool = False) -> models.ScanInitializationResponse: + def zipped_file_scan_async( + self, zip_file: InMemoryZip, scan_type: str, scan_parameters: dict, is_git_diff: bool = False + ) -> models.ScanInitializationResponse: url_path = f'{self.scan_config.get_scans_prefix()}/{self.SCAN_CONTROLLER_PATH}/{scan_type}/repository' files = {'file': ('multiple_files_scan.zip', zip_file.read())} response = self.scan_cycode_client.post( url_path=url_path, data={'is_git_diff': is_git_diff, 'scan_parameters': json.dumps(scan_parameters)}, - files=files + files=files, ) return models.ScanInitializationResponseSchema().load(response.json()) - def multiple_zipped_file_scan_async(self, from_commit_zip_file: InMemoryZip, to_commit_zip_file: InMemoryZip, - scan_type: str, scan_parameters: dict, - is_git_diff: bool = False) -> models.ScanInitializationResponse: - url_path = f'{self.scan_config.get_scans_prefix()}/{self.SCAN_CONTROLLER_PATH}/{scan_type}/repository/commit-range' + def multiple_zipped_file_scan_async( + self, + from_commit_zip_file: InMemoryZip, + to_commit_zip_file: InMemoryZip, + scan_type: str, + scan_parameters: dict, + is_git_diff: bool = False, + ) -> models.ScanInitializationResponse: + url_path = ( + f'{self.scan_config.get_scans_prefix()}/{self.SCAN_CONTROLLER_PATH}/{scan_type}/repository/commit-range' + ) files = { 'file_from_commit': ('multiple_files_scan.zip', from_commit_zip_file.read()), - 'file_to_commit': ('multiple_files_scan.zip', to_commit_zip_file.read()) + 'file_to_commit': ('multiple_files_scan.zip', to_commit_zip_file.read()), } response = self.scan_cycode_client.post( url_path=url_path, data={'is_git_diff': is_git_diff, 'scan_parameters': json.dumps(scan_parameters)}, - files=files + files=files, ) return models.ScanInitializationResponseSchema().load(response.json()) @@ -100,9 +109,11 @@ def get_scan_detections_count(self, scan_id: str) -> int: return response.json().get('count', 0) def commit_range_zipped_file_scan( - self, scan_type: str, zip_file: InMemoryZip, scan_id: str + self, scan_type: str, zip_file: InMemoryZip, scan_id: str ) -> models.ZippedFileScanResult: - url_path = f'{self.scan_config.get_service_name(scan_type)}/{self.SCAN_CONTROLLER_PATH}/commit-range-zipped-file' + url_path = ( + f'{self.scan_config.get_service_name(scan_type)}/{self.SCAN_CONTROLLER_PATH}/commit-range-zipped-file' + ) files = {'file': ('multiple_files_scan.zip', zip_file.read())} response = self.scan_cycode_client.post(url_path=url_path, data={'scan_id': scan_id}, files=files) return self.parse_zipped_file_scan_response(response) diff --git a/cycode/cyclient/scan_config/scan_config_base.py b/cycode/cyclient/scan_config/scan_config_base.py index 957923d4..81fec2a6 100644 --- a/cycode/cyclient/scan_config/scan_config_base.py +++ b/cycode/cyclient/scan_config/scan_config_base.py @@ -2,7 +2,6 @@ class ScanConfigBase(ABC): - @abstractmethod def get_service_name(self, scan_type): pass @@ -17,7 +16,6 @@ def get_detections_prefix(self): class DevScanConfig(ScanConfigBase): - def get_service_name(self, scan_type): if scan_type == 'secret': return '5025' @@ -34,7 +32,6 @@ def get_detections_prefix(self): class DefaultScanConfig(ScanConfigBase): - def get_service_name(self, scan_type): if scan_type == 'secret': return 'secret' diff --git a/cycode/cyclient/scan_config/scan_config_creator.py b/cycode/cyclient/scan_config/scan_config_creator.py index ef20ee1e..adcaf1d9 100644 --- a/cycode/cyclient/scan_config/scan_config_creator.py +++ b/cycode/cyclient/scan_config/scan_config_creator.py @@ -14,8 +14,7 @@ def create_scan_client(client_id: str, client_secret: str) -> ScanClient: else: scan_cycode_client, scan_config = create_scan(client_id, client_secret) - return ScanClient(scan_cycode_client=scan_cycode_client, - scan_config=scan_config) + return ScanClient(scan_cycode_client=scan_cycode_client, scan_config=scan_config) def create_scan(client_id: str, client_secret: str) -> Tuple[CycodeTokenBasedClient, DefaultScanConfig]: diff --git a/cycode/cyclient/utils.py b/cycode/cyclient/utils.py index b8be58dc..4b6c1d1c 100644 --- a/cycode/cyclient/utils.py +++ b/cycode/cyclient/utils.py @@ -3,7 +3,7 @@ def split_list(input_list, batch_size): for i in range(0, len(input_list), batch_size): - yield input_list[i:i + batch_size] + yield input_list[i : i + batch_size] def cpu_count(): diff --git a/poetry.lock b/poetry.lock index acbcbcd3..93240772 100644 --- a/poetry.lock +++ b/poetry.lock @@ -39,6 +39,56 @@ files = [ [package.dependencies] chardet = ">=3.0.2" +[[package]] +name = "black" +version = "23.3.0" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.7" +files = [ + {file = "black-23.3.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:0945e13506be58bf7db93ee5853243eb368ace1c08a24c65ce108986eac65915"}, + {file = "black-23.3.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:67de8d0c209eb5b330cce2469503de11bca4085880d62f1628bd9972cc3366b9"}, + {file = "black-23.3.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:7c3eb7cea23904399866c55826b31c1f55bbcd3890ce22ff70466b907b6775c2"}, + {file = "black-23.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32daa9783106c28815d05b724238e30718f34155653d4d6e125dc7daec8e260c"}, + {file = "black-23.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:35d1381d7a22cc5b2be2f72c7dfdae4072a3336060635718cc7e1ede24221d6c"}, + {file = "black-23.3.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:a8a968125d0a6a404842fa1bf0b349a568634f856aa08ffaff40ae0dfa52e7c6"}, + {file = "black-23.3.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:c7ab5790333c448903c4b721b59c0d80b11fe5e9803d8703e84dcb8da56fec1b"}, + {file = "black-23.3.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:a6f6886c9869d4daae2d1715ce34a19bbc4b95006d20ed785ca00fa03cba312d"}, + {file = "black-23.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f3c333ea1dd6771b2d3777482429864f8e258899f6ff05826c3a4fcc5ce3f70"}, + {file = "black-23.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:11c410f71b876f961d1de77b9699ad19f939094c3a677323f43d7a29855fe326"}, + {file = "black-23.3.0-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:1d06691f1eb8de91cd1b322f21e3bfc9efe0c7ca1f0e1eb1db44ea367dff656b"}, + {file = "black-23.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50cb33cac881766a5cd9913e10ff75b1e8eb71babf4c7104f2e9c52da1fb7de2"}, + {file = "black-23.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:e114420bf26b90d4b9daa597351337762b63039752bdf72bf361364c1aa05925"}, + {file = "black-23.3.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:48f9d345675bb7fbc3dd85821b12487e1b9a75242028adad0333ce36ed2a6d27"}, + {file = "black-23.3.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:714290490c18fb0126baa0fca0a54ee795f7502b44177e1ce7624ba1c00f2331"}, + {file = "black-23.3.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:064101748afa12ad2291c2b91c960be28b817c0c7eaa35bec09cc63aa56493c5"}, + {file = "black-23.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:562bd3a70495facf56814293149e51aa1be9931567474993c7942ff7d3533961"}, + {file = "black-23.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:e198cf27888ad6f4ff331ca1c48ffc038848ea9f031a3b40ba36aced7e22f2c8"}, + {file = "black-23.3.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:3238f2aacf827d18d26db07524e44741233ae09a584273aa059066d644ca7b30"}, + {file = "black-23.3.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:f0bd2f4a58d6666500542b26354978218a9babcdc972722f4bf90779524515f3"}, + {file = "black-23.3.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:92c543f6854c28a3c7f39f4d9b7694f9a6eb9d3c5e2ece488c327b6e7ea9b266"}, + {file = "black-23.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a150542a204124ed00683f0db1f5cf1c2aaaa9cc3495b7a3b5976fb136090ab"}, + {file = "black-23.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:6b39abdfb402002b8a7d030ccc85cf5afff64ee90fa4c5aebc531e3ad0175ddb"}, + {file = "black-23.3.0-py3-none-any.whl", hash = "sha256:ec751418022185b0c1bb7d7736e6933d40bbb14c14a0abcf9123d1b159f98dd4"}, + {file = "black-23.3.0.tar.gz", hash = "sha256:1c7b8d606e728a41ea1ccbd7264677e494e87cf630e399262ced92d4a8dac940"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""} +typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + [[package]] name = "certifi" version = "2023.5.7" @@ -381,6 +431,17 @@ build = ["blurb", "twine", "wheel"] docs = ["sphinx"] test = ["pytest (<5.4)", "pytest-cov"] +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + [[package]] name = "packaging" version = "23.1" @@ -394,13 +455,13 @@ files = [ [[package]] name = "pathspec" -version = "0.8.1" +version = "0.11.1" description = "Utility library for gitignore style pattern matching of file paths." optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.7" files = [ - {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"}, - {file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"}, + {file = "pathspec-0.11.1-py3-none-any.whl", hash = "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293"}, + {file = "pathspec-0.11.1.tar.gz", hash = "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687"}, ] [[package]] @@ -414,6 +475,24 @@ files = [ {file = "pefile-2023.2.7.tar.gz", hash = "sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc"}, ] +[[package]] +name = "platformdirs" +version = "3.8.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +optional = false +python-versions = ">=3.7" +files = [ + {file = "platformdirs-3.8.0-py3-none-any.whl", hash = "sha256:ca9ed98ce73076ba72e092b23d3c93ea6c4e186b3f1c3dad6edd98ff6ffcca2e"}, + {file = "platformdirs-3.8.0.tar.gz", hash = "sha256:b0cabcb11063d21a0b261d557acb0a9d2126350e63b70cdf7db6347baea456dc"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.3", markers = "python_version < \"3.8\""} + +[package.extras] +docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)"] + [[package]] name = "pluggy" version = "1.0.0" @@ -693,6 +772,56 @@ files = [ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] +[[package]] +name = "typed-ast" +version = "1.5.5" +description = "a fork of Python 2 and 3 ast modules with type comment support" +optional = false +python-versions = ">=3.6" +files = [ + {file = "typed_ast-1.5.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4bc1efe0ce3ffb74784e06460f01a223ac1f6ab31c6bc0376a21184bf5aabe3b"}, + {file = "typed_ast-1.5.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f7a8c46a8b333f71abd61d7ab9255440d4a588f34a21f126bbfc95f6049e686"}, + {file = "typed_ast-1.5.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:597fc66b4162f959ee6a96b978c0435bd63791e31e4f410622d19f1686d5e769"}, + {file = "typed_ast-1.5.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d41b7a686ce653e06c2609075d397ebd5b969d821b9797d029fccd71fdec8e04"}, + {file = "typed_ast-1.5.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5fe83a9a44c4ce67c796a1b466c270c1272e176603d5e06f6afbc101a572859d"}, + {file = "typed_ast-1.5.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d5c0c112a74c0e5db2c75882a0adf3133adedcdbfd8cf7c9d6ed77365ab90a1d"}, + {file = "typed_ast-1.5.5-cp310-cp310-win_amd64.whl", hash = "sha256:e1a976ed4cc2d71bb073e1b2a250892a6e968ff02aa14c1f40eba4f365ffec02"}, + {file = "typed_ast-1.5.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c631da9710271cb67b08bd3f3813b7af7f4c69c319b75475436fcab8c3d21bee"}, + {file = "typed_ast-1.5.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b445c2abfecab89a932b20bd8261488d574591173d07827c1eda32c457358b18"}, + {file = "typed_ast-1.5.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc95ffaaab2be3b25eb938779e43f513e0e538a84dd14a5d844b8f2932593d88"}, + {file = "typed_ast-1.5.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61443214d9b4c660dcf4b5307f15c12cb30bdfe9588ce6158f4a005baeb167b2"}, + {file = "typed_ast-1.5.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6eb936d107e4d474940469e8ec5b380c9b329b5f08b78282d46baeebd3692dc9"}, + {file = "typed_ast-1.5.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e48bf27022897577d8479eaed64701ecaf0467182448bd95759883300ca818c8"}, + {file = "typed_ast-1.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:83509f9324011c9a39faaef0922c6f720f9623afe3fe220b6d0b15638247206b"}, + {file = "typed_ast-1.5.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:44f214394fc1af23ca6d4e9e744804d890045d1643dd7e8229951e0ef39429b5"}, + {file = "typed_ast-1.5.5-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:118c1ce46ce58fda78503eae14b7664163aa735b620b64b5b725453696f2a35c"}, + {file = "typed_ast-1.5.5-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4919b808efa61101456e87f2d4c75b228f4e52618621c77f1ddcaae15904fa"}, + {file = "typed_ast-1.5.5-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:fc2b8c4e1bc5cd96c1a823a885e6b158f8451cf6f5530e1829390b4d27d0807f"}, + {file = "typed_ast-1.5.5-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:16f7313e0a08c7de57f2998c85e2a69a642e97cb32f87eb65fbfe88381a5e44d"}, + {file = "typed_ast-1.5.5-cp36-cp36m-win_amd64.whl", hash = "sha256:2b946ef8c04f77230489f75b4b5a4a6f24c078be4aed241cfabe9cbf4156e7e5"}, + {file = "typed_ast-1.5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2188bc33d85951ea4ddad55d2b35598b2709d122c11c75cffd529fbc9965508e"}, + {file = "typed_ast-1.5.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0635900d16ae133cab3b26c607586131269f88266954eb04ec31535c9a12ef1e"}, + {file = "typed_ast-1.5.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57bfc3cf35a0f2fdf0a88a3044aafaec1d2f24d8ae8cd87c4f58d615fb5b6311"}, + {file = "typed_ast-1.5.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:fe58ef6a764de7b4b36edfc8592641f56e69b7163bba9f9c8089838ee596bfb2"}, + {file = "typed_ast-1.5.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d09d930c2d1d621f717bb217bf1fe2584616febb5138d9b3e8cdd26506c3f6d4"}, + {file = "typed_ast-1.5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:d40c10326893ecab8a80a53039164a224984339b2c32a6baf55ecbd5b1df6431"}, + {file = "typed_ast-1.5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fd946abf3c31fb50eee07451a6aedbfff912fcd13cf357363f5b4e834cc5e71a"}, + {file = "typed_ast-1.5.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ed4a1a42df8a3dfb6b40c3d2de109e935949f2f66b19703eafade03173f8f437"}, + {file = "typed_ast-1.5.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:045f9930a1550d9352464e5149710d56a2aed23a2ffe78946478f7b5416f1ede"}, + {file = "typed_ast-1.5.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:381eed9c95484ceef5ced626355fdc0765ab51d8553fec08661dce654a935db4"}, + {file = "typed_ast-1.5.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:bfd39a41c0ef6f31684daff53befddae608f9daf6957140228a08e51f312d7e6"}, + {file = "typed_ast-1.5.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8c524eb3024edcc04e288db9541fe1f438f82d281e591c548903d5b77ad1ddd4"}, + {file = "typed_ast-1.5.5-cp38-cp38-win_amd64.whl", hash = "sha256:7f58fabdde8dcbe764cef5e1a7fcb440f2463c1bbbec1cf2a86ca7bc1f95184b"}, + {file = "typed_ast-1.5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:042eb665ff6bf020dd2243307d11ed626306b82812aba21836096d229fdc6a10"}, + {file = "typed_ast-1.5.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:622e4a006472b05cf6ef7f9f2636edc51bda670b7bbffa18d26b255269d3d814"}, + {file = "typed_ast-1.5.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1efebbbf4604ad1283e963e8915daa240cb4bf5067053cf2f0baadc4d4fb51b8"}, + {file = "typed_ast-1.5.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0aefdd66f1784c58f65b502b6cf8b121544680456d1cebbd300c2c813899274"}, + {file = "typed_ast-1.5.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:48074261a842acf825af1968cd912f6f21357316080ebaca5f19abbb11690c8a"}, + {file = "typed_ast-1.5.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:429ae404f69dc94b9361bb62291885894b7c6fb4640d561179548c849f8492ba"}, + {file = "typed_ast-1.5.5-cp39-cp39-win_amd64.whl", hash = "sha256:335f22ccb244da2b5c296e6f96b06ee9bed46526db0de38d2f0e5a6597b81155"}, + {file = "typed_ast-1.5.5.tar.gz", hash = "sha256:94282f7a354f36ef5dbce0ef3467ebf6a258e370ab33d5b40c249fa996e590dd"}, +] + [[package]] name = "types-pyyaml" version = "6.0.12.9" @@ -706,13 +835,13 @@ files = [ [[package]] name = "typing-extensions" -version = "4.5.0" +version = "4.7.1" description = "Backported and Experimental Type Hints for Python 3.7+" optional = false python-versions = ">=3.7" files = [ - {file = "typing_extensions-4.5.0-py3-none-any.whl", hash = "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4"}, - {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"}, + {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, + {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, ] [[package]] @@ -750,4 +879,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = ">=3.7,<3.12" -content-hash = "13ef296063bf977819668451d2ba229a9009e3cee5478f5a9d2c4aef3e5e2b2f" +content-hash = "833e8a506d7c9c0ef2585dc822d37d9b665d51cd0ef4c49b3d6c3397bdd8ecea" diff --git a/pyproject.toml b/pyproject.toml index 39d01626..69232760 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ click = ">=8.1.0,<8.2.0" colorama = ">=0.4.3,<0.5.0" pyyaml = ">=6.0,<7.0" marshmallow = ">=3.8.0,<3.9.0" -pathspec = ">=0.8.0,<0.9.0" +pathspec = ">=0.11.1,<0.12.0" gitpython = ">=3.1.30,<3.2.0" arrow = ">=0.17.0,<0.18.0" binaryornot = ">=0.4.4,<0.5.0" @@ -49,6 +49,9 @@ responses = ">=0.23.1,<0.24.0" pyinstaller = ">=5.11.0,<5.12.0" dunamai = ">=1.16.1,<1.17.0" +[tool.poetry.group.dev.dependencies] +black = ">=23.3.0,<23.4.0" + [tool.pytest.ini_options] log_cli = true @@ -61,6 +64,26 @@ metadata = false vcs = "git" style = "pep440" +[tool.black] +line-length = 120 +skip-string-normalization=true +target-version = ['py37'] +include = '\.pyi?$' +exclude = ''' +( + /( + \.git + | \.mypy_cache + | .idea + | .pytest_cache + | venv + | htmlcov + | build + | dist + )/ +) +''' + [build-system] requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning"] build-backend = "poetry_dynamic_versioning.backend" diff --git a/tests/__init__.py b/tests/__init__.py index b475f6f5..421a461f 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -23,7 +23,7 @@ "kind": "Deployment", "name": "nginx-deployment", } - ] + ], } } diff --git a/tests/cli/test_code_scanner.py b/tests/cli/test_code_scanner.py index ee4e19bb..c4c23e2a 100644 --- a/tests/cli/test_code_scanner.py +++ b/tests/cli/test_code_scanner.py @@ -15,15 +15,19 @@ def ctx(): return click.Context(click.Command('path'), obj={'verbose': False, 'output': 'text'}) -@pytest.mark.parametrize('exception, expected_soft_fail', [ - (custom_exceptions.NetworkError(400, 'msg', Response()), True), - (custom_exceptions.ScanAsyncError('msg'), True), - (custom_exceptions.HttpUnauthorizedError('msg', Response()), True), - (custom_exceptions.ZipTooLargeError(1000), True), - (InvalidGitRepositoryError(), None), -]) +@pytest.mark.parametrize( + 'exception, expected_soft_fail', + [ + (custom_exceptions.NetworkError(400, 'msg', Response()), True), + (custom_exceptions.ScanAsyncError('msg'), True), + (custom_exceptions.HttpUnauthorizedError('msg', Response()), True), + (custom_exceptions.ZipTooLargeError(1000), True), + (InvalidGitRepositoryError(), None), + ], +) def test_handle_exception_soft_fail( - ctx: click.Context, exception: custom_exceptions.CycodeError, expected_soft_fail: bool): + ctx: click.Context, exception: custom_exceptions.CycodeError, expected_soft_fail: bool +): with ctx: _handle_exception(ctx, exception) diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py index f2784626..92ec2e58 100644 --- a/tests/cli/test_main.py +++ b/tests/cli/test_main.py @@ -28,7 +28,7 @@ def _is_json(plain: str) -> bool: @pytest.mark.parametrize('output', ['text', 'json']) @pytest.mark.parametrize('option_space', ['scan', 'global']) def test_passing_output_option( - output: str, option_space: str, scan_client: 'ScanClient', api_token_response: responses.Response + output: str, option_space: str, scan_client: 'ScanClient', api_token_response: responses.Response ): scan_type = 'secret' diff --git a/tests/conftest.py b/tests/conftest.py index 3af4b303..72860e70 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,10 +12,7 @@ _CLIENT_ID = 'b1234568-0eaa-1234-beb8-6f0c12345678' _CLIENT_SECRET = 'a12345a-42b2-1234-3bdd-c0130123456' -CLI_ENV_VARS = { - 'CYCODE_CLIENT_ID': _CLIENT_ID, - 'CYCODE_CLIENT_SECRET': _CLIENT_SECRET -} +CLI_ENV_VARS = {'CYCODE_CLIENT_ID': _CLIENT_ID, 'CYCODE_CLIENT_SECRET': _CLIENT_SECRET} TEST_FILES_PATH = Path(__file__).parent.joinpath('test_files').absolute() @@ -43,9 +40,9 @@ def api_token_response(api_token_url) -> responses.Response: json={ 'token': _EXPECTED_API_TOKEN, 'refresh_token': '12345678-0c68-1234-91ba-a13123456789', - 'expires_in': 86400 + 'expires_in': 86400, }, - status=200 + status=200, ) diff --git a/tests/cyclient/test_auth_client.py b/tests/cyclient/test_auth_client.py index a74b2ec7..d6647b27 100644 --- a/tests/cyclient/test_auth_client.py +++ b/tests/cyclient/test_auth_client.py @@ -4,14 +4,18 @@ from requests import Timeout from cycode.cyclient.auth_client import AuthClient -from cycode.cyclient.models import AuthenticationSession, ApiTokenGenerationPollingResponse, \ - ApiTokenGenerationPollingResponseSchema +from cycode.cyclient.models import ( + AuthenticationSession, + ApiTokenGenerationPollingResponse, + ApiTokenGenerationPollingResponseSchema, +) from cycode.cli.exceptions.custom_exceptions import CycodeError @pytest.fixture(scope='module') def code_challenge() -> str: from cycode.cli.auth.auth_manager import AuthManager + code_challenge, _ = AuthManager()._generate_pkce_code_pair() return code_challenge @@ -19,6 +23,7 @@ def code_challenge() -> str: @pytest.fixture(scope='module') def code_verifier() -> str: from cycode.cli.auth.auth_manager import AuthManager + _, code_verifier = AuthManager()._generate_pkce_code_pair() return code_verifier @@ -31,18 +36,12 @@ def auth_client() -> AuthClient: @pytest.fixture(scope='module', name='start_url') def auth_start_url(client: AuthClient) -> str: # TODO(MarshalX): create database of constants of endpoints. remove hardcoded paths - return client.cycode_client.build_full_url( - client.cycode_client.api_url, - f'{client.AUTH_CONTROLLER_PATH}/start' - ) + return client.cycode_client.build_full_url(client.cycode_client.api_url, f'{client.AUTH_CONTROLLER_PATH}/start') @pytest.fixture(scope='module', name='token_url') def auth_token_url(client: AuthClient) -> str: - return client.cycode_client.build_full_url( - client.cycode_client.api_url, - f'{client.AUTH_CONTROLLER_PATH}/token' - ) + return client.cycode_client.build_full_url(client.cycode_client.api_url, f'{client.AUTH_CONTROLLER_PATH}/token') _SESSION_ID = '4cff1234-a209-47ed-ab2f-85676912345c' @@ -121,8 +120,8 @@ def test_get_api_token_success_completed(client: AuthClient, token_url: str, cod 'secret': 'a123450a-42b2-4ad5-8bdd-c0130123456', 'description': 'cycode cli api token', 'createdByUserId': None, - 'createdAt': '2023-04-26T11:38:54+00:00' - } + 'createdAt': '2023-04-26T11:38:54+00:00', + }, } expected_response = ApiTokenGenerationPollingResponseSchema().load(expected_json) diff --git a/tests/cyclient/test_client_base.py b/tests/cyclient/test_client_base.py index 744ba097..dff25c52 100644 --- a/tests/cyclient/test_client_base.py +++ b/tests/cyclient/test_client_base.py @@ -8,7 +8,7 @@ def test_mandatory_headers(): } client = CycodeClientBase(config.cycode_api_url) - + assert client.MANDATORY_HEADERS == expected_headers @@ -21,9 +21,7 @@ def test_get_request_headers(): def test_get_request_headers_with_additional(): client = CycodeClientBase(config.cycode_api_url) - additional_headers = { - 'Authorize': 'Token test' - } + additional_headers = {'Authorize': 'Token test'} expected_headers = {**client.MANDATORY_HEADERS, **additional_headers} assert client.get_request_headers(additional_headers) == expected_headers diff --git a/tests/cyclient/test_dev_based_client.py b/tests/cyclient/test_dev_based_client.py index 6ce9f3be..e5fcfecd 100644 --- a/tests/cyclient/test_dev_based_client.py +++ b/tests/cyclient/test_dev_based_client.py @@ -5,9 +5,7 @@ def test_get_request_headers(): client = CycodeDevBasedClient(config.cycode_api_url) - dev_based_headers = { - 'X-Tenant-Id': config.dev_tenant_id - } + dev_based_headers = {'X-Tenant-Id': config.dev_tenant_id} expected_headers = {**client.MANDATORY_HEADERS, **dev_based_headers} assert client.get_request_headers() == expected_headers diff --git a/tests/cyclient/test_scan_client.py b/tests/cyclient/test_scan_client.py index d4e11128..41c15a71 100644 --- a/tests/cyclient/test_scan_client.py +++ b/tests/cyclient/test_scan_client.py @@ -52,7 +52,7 @@ def get_zipped_file_scan_response(url: str, scan_id: UUID = None) -> responses.R json_response = { 'did_detect': True, - 'scan_id': str(scan_id), # not always as expected due to _get_scan_id and passing scan_id to cxt of CLI + 'scan_id': str(scan_id), # not always as expected due to _get_scan_id and passing scan_id to cxt of CLI 'detections_per_file': [ { 'file_name': str(_ZIP_CONTENT_PATH.joinpath('secrets.py')), @@ -73,13 +73,13 @@ def get_zipped_file_scan_response(url: str, scan_id: UUID = None) -> responses.R 'file_path': str(_ZIP_CONTENT_PATH), 'file_name': 'secrets.py', 'file_extension': '.py', - 'should_resolve_upon_branch_deletion': False - } + 'should_resolve_upon_branch_deletion': False, + }, } - ] + ], } ], - 'report_url': None + 'report_url': None, } return responses.Response(method=responses.POST, url=url, json=json_response, status=200) @@ -99,7 +99,7 @@ def test_zipped_file_scan(scan_type: str, scan_client: ScanClient, api_token_res url, zip_file = zip_scan_resources(scan_type, scan_client) expected_scan_id = uuid4() - responses.add(api_token_response) # mock token based client + responses.add(api_token_response) # mock token based client responses.add(get_zipped_file_scan_response(url, expected_scan_id)) zipped_file_scan_response = scan_client.zipped_file_scan( @@ -114,7 +114,7 @@ def test_zipped_file_scan_unauthorized_error(scan_type: str, scan_client: ScanCl url, zip_file = zip_scan_resources(scan_type, scan_client) expected_scan_id = uuid4().hex - responses.add(api_token_response) # mock token based client + responses.add(api_token_response) # mock token based client responses.add(method=responses.POST, url=url, status=401) with pytest.raises(HttpUnauthorizedError) as e_info: @@ -132,7 +132,7 @@ def test_zipped_file_scan_bad_request_error(scan_type: str, scan_client: ScanCli expected_status_code = 400 expected_response_text = 'Bad Request' - responses.add(api_token_response) # mock token based client + responses.add(api_token_response) # mock token based client responses.add(method=responses.POST, url=url, status=expected_status_code, body=expected_response_text) with pytest.raises(CycodeError) as e_info: @@ -159,7 +159,7 @@ def test_zipped_file_scan_timeout_error(scan_type: str, scan_client: ScanClient, timeout_error = Timeout() timeout_error.response = timeout_response - responses.add(api_token_response) # mock token based client + responses.add(api_token_response) # mock token based client responses.add(method=responses.POST, url=scan_url, body=timeout_error, status=504) with pytest.raises(CycodeError) as e_info: @@ -175,7 +175,7 @@ def test_zipped_file_scan_connection_error(scan_type: str, scan_client: ScanClie url, zip_file = zip_scan_resources(scan_type, scan_client) expected_scan_id = uuid4().hex - responses.add(api_token_response) # mock token based client + responses.add(api_token_response) # mock token based client responses.add(method=responses.POST, url=url, body=ProxyError()) with pytest.raises(CycodeError) as e_info: diff --git a/tests/cyclient/test_token_based_client.py b/tests/cyclient/test_token_based_client.py index 1ffa8948..b906a976 100644 --- a/tests/cyclient/test_token_based_client.py +++ b/tests/cyclient/test_token_based_client.py @@ -31,9 +31,7 @@ def test_api_token_expired(token_based_client: CycodeTokenBasedClient, api_token def test_get_request_headers(token_based_client: CycodeTokenBasedClient, api_token: str): - token_based_headers = { - 'Authorization': f'Bearer {_EXPECTED_API_TOKEN}' - } + token_based_headers = {'Authorization': f'Bearer {_EXPECTED_API_TOKEN}'} expected_headers = {**token_based_client.MANDATORY_HEADERS, **token_based_headers} assert token_based_client.get_request_headers() == expected_headers diff --git a/tests/user_settings/test_configuration_manager.py b/tests/user_settings/test_configuration_manager.py index 18c9cb9f..8f4e01f1 100644 --- a/tests/user_settings/test_configuration_manager.py +++ b/tests/user_settings/test_configuration_manager.py @@ -16,8 +16,9 @@ def test_get_base_url_from_environment_variable(mocker): # Arrange - configuration_manager = _configure_mocks(mocker, ENV_VARS_BASE_URL_VALUE, LOCAL_CONFIG_FILE_BASE_URL_VALUE, - GLOBAL_CONFIG_BASE_URL_VALUE) + configuration_manager = _configure_mocks( + mocker, ENV_VARS_BASE_URL_VALUE, LOCAL_CONFIG_FILE_BASE_URL_VALUE, GLOBAL_CONFIG_BASE_URL_VALUE + ) # Act result = configuration_manager.get_cycode_api_url() @@ -28,8 +29,9 @@ def test_get_base_url_from_environment_variable(mocker): def test_get_base_url_from_local_config(mocker): # Arrange - configuration_manager = _configure_mocks(mocker, None, LOCAL_CONFIG_FILE_BASE_URL_VALUE, - GLOBAL_CONFIG_BASE_URL_VALUE) + configuration_manager = _configure_mocks( + mocker, None, LOCAL_CONFIG_FILE_BASE_URL_VALUE, GLOBAL_CONFIG_BASE_URL_VALUE + ) # Act result = configuration_manager.get_cycode_api_url() @@ -60,12 +62,12 @@ def test_get_base_url_not_configured(mocker): assert result == DEFAULT_CYCODE_API_URL -def _configure_mocks(mocker, - expected_env_var_base_url, - expected_local_config_file_base_url, - expected_global_config_file_base_url): - mocker.patch.object(ConfigurationManager, 'get_api_url_from_environment_variables', - return_value=expected_env_var_base_url) +def _configure_mocks( + mocker, expected_env_var_base_url, expected_local_config_file_base_url, expected_global_config_file_base_url +): + mocker.patch.object( + ConfigurationManager, 'get_api_url_from_environment_variables', return_value=expected_env_var_base_url + ) configuration_manager = ConfigurationManager() configuration_manager.local_config_file_manager = Mock() configuration_manager.local_config_file_manager.get_api_url.return_value = expected_local_config_file_base_url diff --git a/tests/user_settings/test_user_settings_commands.py b/tests/user_settings/test_user_settings_commands.py index 600284d5..cd5e5974 100644 --- a/tests/user_settings/test_user_settings_commands.py +++ b/tests/user_settings/test_user_settings_commands.py @@ -7,13 +7,16 @@ def test_set_credentials_no_exist_credentials_in_file(mocker): # Arrange client_id_user_input = "new client id" client_secret_user_input = "new client secret" - mocker.patch('cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', - return_value=(None, None)) + mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', + return_value=(None, None), + ) # side effect - multiple return values, each item in the list represent return of a call mocker.patch('click.prompt', side_effect=[client_id_user_input, client_secret_user_input]) mocked_update_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials_file') + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials_file' + ) click = CliRunner() # Act @@ -27,13 +30,16 @@ def test_set_credentials_update_current_credentials_in_file(mocker): # Arrange client_id_user_input = "new client id" client_secret_user_input = "new client secret" - mocker.patch('cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', - return_value=('client id file', 'client secret file')) + mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', + return_value=('client id file', 'client secret file'), + ) # side effect - multiple return values, each item in the list represent return of a call mocker.patch('click.prompt', side_effect=[client_id_user_input, client_secret_user_input]) mocked_update_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials_file') + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials_file' + ) click = CliRunner() # Act @@ -47,13 +53,16 @@ def test_set_credentials_update_only_client_id(mocker): # Arrange client_id_user_input = "new client id" current_client_id = 'client secret file' - mocker.patch('cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', - return_value=('client id file', 'client secret file')) + mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', + return_value=('client id file', 'client secret file'), + ) # side effect - multiple return values, each item in the list represent return of a call mocker.patch('click.prompt', side_effect=[client_id_user_input, '']) mocked_update_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials_file') + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials_file' + ) click = CliRunner() # Act @@ -67,13 +76,16 @@ def test_set_credentials_update_only_client_secret(mocker): # Arrange client_secret_user_input = "new client secret" current_client_id = 'client secret file' - mocker.patch('cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', - return_value=(current_client_id, 'client secret file')) + mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', + return_value=(current_client_id, 'client secret file'), + ) # side effect - multiple return values, each item in the list represent return of a call mocker.patch('click.prompt', side_effect=['', client_secret_user_input]) mocked_update_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials_file') + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials_file' + ) click = CliRunner() # Act @@ -87,13 +99,16 @@ def test_set_credentials_should_not_update_file(mocker): # Arrange client_id_user_input = "" client_secret_user_input = "" - mocker.patch('cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', - return_value=('client id file', 'client secret file')) + mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', + return_value=('client id file', 'client secret file'), + ) # side effect - multiple return values, each item in the list represent return of a call mocker.patch('click.prompt', side_effect=[client_id_user_input, client_secret_user_input]) mocked_update_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials_file') + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials_file' + ) click = CliRunner() # Act @@ -101,4 +116,3 @@ def test_set_credentials_should_not_update_file(mocker): # Assert assert not mocked_update_credentials.called - From 67f0900571980e94cca31b6d2c99e95948227d5c Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 5 Jul 2023 16:25:31 +0200 Subject: [PATCH 006/257] CM-24997 - Integrate Ruff linter (#134) also, fix truncated help text of commands --- .github/workflows/ruff.yml | 36 ++++ cycode/cli/auth/auth_command.py | 16 +- cycode/cli/auth/auth_manager.py | 13 +- cycode/cli/ci_integrations.py | 53 +++-- cycode/cli/code_scanner.py | 182 +++++++++--------- cycode/cli/config.py | 5 +- cycode/cli/consts.py | 55 +++--- .../maven/base_restore_maven_dependencies.py | 10 +- .../maven/restore_maven_dependencies.py | 2 +- cycode/cli/helpers/sca_code_scanner.py | 20 +- cycode/cli/main.py | 91 ++++----- cycode/cli/models.py | 10 +- cycode/cli/printers/base_printer.py | 4 +- cycode/cli/printers/base_table_printer.py | 4 +- cycode/cli/printers/console_printer.py | 11 +- cycode/cli/printers/json_printer.py | 4 +- cycode/cli/printers/sca_table_printer.py | 6 +- cycode/cli/printers/table.py | 9 +- cycode/cli/printers/table_models.py | 2 +- cycode/cli/printers/table_printer.py | 10 +- cycode/cli/printers/text_printer.py | 17 +- cycode/cli/user_settings/base_file_manager.py | 3 +- .../cli/user_settings/config_file_manager.py | 13 +- .../user_settings/configuration_manager.py | 32 +-- .../cli/user_settings/credentials_manager.py | 4 +- .../user_settings/user_settings_commands.py | 60 +++--- cycode/cli/utils/path_utils.py | 10 +- cycode/cli/utils/progress_bar.py | 13 +- cycode/cli/utils/scan_batch.py | 8 +- cycode/cli/utils/shell_executor.py | 8 +- cycode/cli/utils/string_utils.py | 11 +- cycode/cli/utils/task_timer.py | 8 +- cycode/cli/utils/yaml_utils.py | 9 +- cycode/cli/zip_file.py | 4 +- cycode/cyclient/__init__.py | 3 +- cycode/cyclient/auth_client.py | 5 +- cycode/cyclient/config.py | 49 ++--- cycode/cyclient/config_dev.py | 6 +- cycode/cyclient/cycode_client_base.py | 26 +-- cycode/cyclient/cycode_dev_based_client.py | 6 +- cycode/cyclient/cycode_token_based_client.py | 5 +- cycode/cyclient/models.py | 37 ++-- cycode/cyclient/scan_client.py | 17 +- .../cyclient/scan_config/scan_config_base.py | 14 +- .../scan_config/scan_config_creator.py | 12 +- poetry.lock | 28 ++- pyproject.toml | 43 +++++ tests/__init__.py | 10 +- tests/cli/test_code_scanner.py | 25 ++- tests/cli/test_main.py | 5 +- tests/cyclient/test_auth_client.py | 4 +- tests/cyclient/test_client_base.py | 2 +- tests/cyclient/test_scan_client.py | 17 +- tests/cyclient/test_token_based_client.py | 4 +- tests/test_files/zip_content/__init__.py | 0 tests/test_files/zip_content/sast.py | 3 +- tests/test_files/zip_content/secrets.py | 2 +- tests/test_models.py | 10 +- .../test_configuration_manager.py | 2 +- .../test_user_settings_commands.py | 16 +- tests/utils/test_string_utils.py | 4 +- 61 files changed, 609 insertions(+), 489 deletions(-) create mode 100644 .github/workflows/ruff.yml create mode 100644 tests/test_files/zip_content/__init__.py diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml new file mode 100644 index 00000000..0fc3ddb5 --- /dev/null +++ b/.github/workflows/ruff.yml @@ -0,0 +1,36 @@ +name: Ruff (linter) + +on: [ pull_request, push ] + +jobs: + ruff: + runs-on: ubuntu-latest + + steps: + - name: Run Cimon + uses: cycodelabs/cimon-action@v0 + with: + client-id: ${{ secrets.CIMON_CLIENT_ID }} + secret: ${{ secrets.CIMON_SECRET }} + prevent: true + allowed-hosts: > + files.pythonhosted.org + install.python-poetry.org + pypi.org + + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: 3.7 + + - name: Setup Poetry + uses: snok/install-poetry@v1 + + - name: Install dependencies + run: poetry install + + - name: Run linter check + run: poetry run ruff check . diff --git a/cycode/cli/auth/auth_command.py b/cycode/cli/auth/auth_command.py index e19085fb..63384276 100644 --- a/cycode/cli/auth/auth_command.py +++ b/cycode/cli/auth/auth_command.py @@ -1,21 +1,23 @@ -import click import traceback -from cycode.cli.models import CliResult, CliErrors, CliError -from cycode.cli.printers import ConsolePrinter +import click + from cycode.cli.auth.auth_manager import AuthManager +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.user_settings.credentials_manager import CredentialsManager -from cycode.cli.exceptions.custom_exceptions import AuthProcessError, NetworkError, HttpUnauthorizedError from cycode.cyclient import logger from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient -@click.group(invoke_without_command=True) +@click.group( + invoke_without_command=True, short_help='Authenticates your machine to associate CLI with your cycode account' +) @click.pass_context def authenticate(context: click.Context): - """Authenticates your machine to associate CLI with your cycode account""" if context.invoked_subcommand is not None: - # if it is a subcommand do nothing + # if it is a subcommand, do nothing return try: diff --git a/cycode/cli/auth/auth_manager.py b/cycode/cli/auth/auth_manager.py index d8d018d8..3a177467 100644 --- a/cycode/cli/auth/auth_manager.py +++ b/cycode/cli/auth/auth_manager.py @@ -1,23 +1,24 @@ import time import webbrowser -from requests import Request from typing import Optional +from requests import Request + from cycode.cli.exceptions.custom_exceptions import AuthProcessError -from cycode.cli.utils.string_utils import generate_random_string, hash_string_to_sha256 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 generate_random_string, hash_string_to_sha256 +from cycode.cyclient import logger from cycode.cyclient.auth_client import AuthClient from cycode.cyclient.models import ApiToken, ApiTokenGenerationPollingResponse -from cycode.cyclient import logger class AuthManager: CODE_VERIFIER_LENGTH = 101 POLLING_WAIT_INTERVAL_IN_SECONDS = 3 POLLING_TIMEOUT_IN_SECONDS = 180 - FAILED_POLLING_STATUS = "Error" - COMPLETED_POLLING_STATUS = "Completed" + FAILED_POLLING_STATUS = 'Error' + COMPLETED_POLLING_STATUS = 'Completed' configuration_manager: ConfigurationManager credentials_manager: CredentialsManager @@ -56,7 +57,7 @@ def redirect_to_login_page(self, code_challenge: str, session_id: str): def get_api_token(self, session_id: str, code_verifier: str) -> Optional[ApiToken]: api_token = self.get_api_token_polling(session_id, code_verifier) if api_token is None: - raise AuthProcessError("getting api token is completed, but the token is missing") + raise AuthProcessError('getting api token is completed, but the token is missing') return api_token def get_api_token_polling(self, session_id: str, code_verifier: str) -> Optional[ApiToken]: diff --git a/cycode/cli/ci_integrations.py b/cycode/cli/ci_integrations.py index a98abf4d..5ea93e38 100644 --- a/cycode/cli/ci_integrations.py +++ b/cycode/cli/ci_integrations.py @@ -1,61 +1,60 @@ import os + import click def github_action_range(): - before_sha = os.getenv("BEFORE_SHA") - push_base_sha = os.getenv("BASE_SHA") - pr_base_sha = os.getenv("PR_BASE_SHA") - default_branch = os.getenv("DEFAULT_BRANCH") - head_sha = os.getenv("GITHUB_SHA") - ref = os.getenv("GITHUB_REF") - - click.echo(f"{before_sha}, {push_base_sha}, {pr_base_sha}, {default_branch}, {head_sha}, {ref}") + before_sha = os.getenv('BEFORE_SHA') + push_base_sha = os.getenv('BASE_SHA') + pr_base_sha = os.getenv('PR_BASE_SHA') + default_branch = os.getenv('DEFAULT_BRANCH') + head_sha = os.getenv('GITHUB_SHA') + ref = os.getenv('GITHUB_REF') + + click.echo(f'{before_sha}, {push_base_sha}, {pr_base_sha}, {default_branch}, {head_sha}, {ref}') if before_sha and before_sha != NO_COMMITS: - return f"{before_sha}..." + return f'{before_sha}...' - return "..." + return '...' # if pr_base_sha and pr_base_sha != FIRST_COMMIT: - # return f"{pr_base_sha}..." # # if push_base_sha and push_base_sha != "null": - # return f"{push_base_sha}..." def circleci_range(): - before_sha = os.getenv("BEFORE_SHA") - current_sha = os.getenv("CURRENT_SHA") - commit_range = f"{before_sha}...{current_sha}" - click.echo(f"commit range: {commit_range}") + before_sha = os.getenv('BEFORE_SHA') + current_sha = os.getenv('CURRENT_SHA') + commit_range = f'{before_sha}...{current_sha}' + click.echo(f'commit range: {commit_range}') if not commit_range.startswith('...'): return commit_range - commit_sha = os.getenv("CIRCLE_SHA1", "HEAD") + commit_sha = os.getenv('CIRCLE_SHA1', 'HEAD') return f'{commit_sha}~1...' def gitlab_range(): - before_sha = os.getenv("CI_COMMIT_BEFORE_SHA") - commit_sha = os.getenv("CI_COMMIT_SHA", "HEAD") + before_sha = os.getenv('CI_COMMIT_BEFORE_SHA') + commit_sha = os.getenv('CI_COMMIT_SHA', 'HEAD') if before_sha and before_sha != NO_COMMITS: - return f"{before_sha}..." + return f'{before_sha}...' - return f"{commit_sha}" + return f'{commit_sha}' def get_commit_range(): - if os.getenv("GITHUB_ACTIONS"): + if os.getenv('GITHUB_ACTIONS'): return github_action_range() - elif os.getenv("CIRCLECI"): + if os.getenv('CIRCLECI'): return circleci_range() - elif os.getenv("GITLAB_CI"): + if os.getenv('GITLAB_CI'): return gitlab_range() - else: - raise click.ClickException("CI framework is not supported") + + raise click.ClickException('CI framework is not supported') -NO_COMMITS = "0000000000000000000000000000000000000000" +NO_COMMITS = '0000000000000000000000000000000000000000' diff --git a/cycode/cli/code_scanner.py b/cycode/cli/code_scanner.py index 47d64ccc..bd72ebd9 100644 --- a/cycode/cli/code_scanner.py +++ b/cycode/cli/code_scanner.py @@ -1,4 +1,3 @@ -import click import json import logging import os @@ -6,49 +5,50 @@ import time import traceback from platform import platform -from uuid import uuid4, UUID -from typing import TYPE_CHECKING, Callable, List, Optional, Dict, Tuple - -from git import Repo, NULL_TREE, InvalidGitRepositoryError from sys import getsizeof +from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple +from uuid import UUID, uuid4 + +import click +from git import NULL_TREE, InvalidGitRepositoryError, Repo -from cycode.cli.printers import ConsolePrinter -from cycode.cli.models import Document, DocumentDetections, Severity, CliError, CliErrors, LocalScanResult -from cycode.cli.ci_integrations import get_commit_range from cycode.cli import consts +from cycode.cli.ci_integrations import get_commit_range from cycode.cli.config import configuration_manager -from cycode.cli.utils.progress_bar import ProgressBarSection -from cycode.cli.utils.scan_utils import set_issue_detected +from cycode.cli.exceptions import custom_exceptions +from cycode.cli.helpers import sca_code_scanner +from cycode.cli.models import CliError, CliErrors, Document, DocumentDetections, LocalScanResult, Severity +from cycode.cli.printers import ConsolePrinter +from cycode.cli.user_settings.config_file_manager import ConfigFileManager +from cycode.cli.utils import scan_utils from cycode.cli.utils.path_utils import ( - is_sub_path, - is_binary_file, + get_file_content, get_file_size, - get_relevant_files_in_path, get_path_by_os, - get_file_content, + get_relevant_files_in_path, + is_binary_file, + is_sub_path, ) +from cycode.cli.utils.progress_bar import ProgressBarSection +from cycode.cli.utils.progress_bar import logger as progress_bar_logger from cycode.cli.utils.scan_batch import run_parallel_batched_scan +from cycode.cli.utils.scan_utils import set_issue_detected from cycode.cli.utils.string_utils import get_content_size, is_binary_content from cycode.cli.utils.task_timer import TimeoutAfter -from cycode.cli.utils import scan_utils -from cycode.cli.user_settings.config_file_manager import ConfigFileManager from cycode.cli.zip_file import InMemoryZip -from cycode.cli.exceptions import custom_exceptions -from cycode.cli.helpers import sca_code_scanner from cycode.cyclient import logger -from cycode.cli.utils.progress_bar import logger as progress_bar_logger -from cycode.cyclient.models import ZippedFileScanResult, Detection, DetectionsPerFile, DetectionSchema +from cycode.cyclient.models import Detection, DetectionSchema, DetectionsPerFile, ZippedFileScanResult if TYPE_CHECKING: - from cycode.cyclient.scan_client import ScanClient - from cycode.cyclient.models import ScanDetailsResponse from cycode.cli.utils.progress_bar import BaseProgressBar + from cycode.cyclient.models import ScanDetailsResponse + from cycode.cyclient.scan_client import ScanClient start_scan_time = time.time() -@click.command() -@click.argument("path", nargs=1, type=click.STRING, required=True) +@click.command(short_help='Scan git repository including its history') +@click.argument('path', nargs=1, type=click.STRING, required=True) @click.option( '--branch', '-b', @@ -59,14 +59,13 @@ ) @click.pass_context def scan_repository(context: click.Context, path: str, branch: str): - """Scan git repository including its history""" try: logger.debug('Starting repository scan process, %s', {'path': path, 'branch': branch}) scan_type = context.obj['scan_type'] monitor = context.obj.get('monitor') if monitor and scan_type != consts.SCA_SCAN_TYPE: - raise click.ClickException(f'Monitor flag is currently supported for SCA scan type only') + raise click.ClickException('Monitor flag is currently supported for SCA scan type only') progress_bar = context.obj['progress_bar'] @@ -77,10 +76,7 @@ def scan_repository(context: click.Context, path: str, branch: str): for file in file_entries: progress_bar.update(ProgressBarSection.PREPARE_LOCAL_FILES) - if monitor: - path = file.path - else: - path = get_path_by_os(os.path.join(path, file.path)) + path = file.path if monitor else get_path_by_os(os.path.join(path, file.path)) documents_to_scan.append(Document(path, file.data_stream.read().decode('UTF-8', errors='replace'))) @@ -96,19 +92,18 @@ def scan_repository(context: click.Context, path: str, branch: str): _handle_exception(context, e) -@click.command() -@click.argument("path", nargs=1, type=click.STRING, required=True) +@click.command(short_help='Scan all the commits history in this git repository') +@click.argument('path', nargs=1, type=click.STRING, required=True) @click.option( - "--commit_range", - "-r", - help='Scan a commit range in this git repository, by default cycode scans all ' 'commit history (example: HEAD~1)', + '--commit_range', + '-r', + help='Scan a commit range in this git repository, by default cycode scans all commit history (example: HEAD~1)', type=click.STRING, - default="--all", + default='--all', required=False, ) @click.pass_context def scan_repository_commit_history(context: click.Context, path: str, commit_range: str): - """Scan all the commits history in this git repository""" try: logger.debug('Starting commit history scan process, %s', {'path': path, 'commit_range': commit_range}) return scan_commit_range(context, path=path, commit_range=commit_range) @@ -174,31 +169,30 @@ def scan_commit_range(context: click.Context, path: str, commit_range: str, max_ return scan_documents(context, documents_to_scan, is_git_diff=True, is_commit_range=True) -@click.command() +@click.command( + short_help='Execute scan in a CI environment which relies on the ' + 'CYCODE_TOKEN and CYCODE_REPO_LOCATION environment variables' +) @click.pass_context def scan_ci(context: click.Context): - """Execute scan in a CI environment which relies on the - CYCODE_TOKEN and CYCODE_REPO_LOCATION environment variables""" return scan_commit_range(context, path=os.getcwd(), commit_range=get_commit_range()) -@click.command() -@click.argument("path", nargs=1, type=click.STRING, required=True) +@click.command(short_help='Scan the files in the path supplied in the command') +@click.argument('path', nargs=1, type=click.STRING, required=True) @click.pass_context def scan_path(context: click.Context, path): - """Scan the files in the path supplied in the command""" logger.debug('Starting path scan process, %s', {'path': path}) - files_to_scan = get_relevant_files_in_path(path=path, exclude_patterns=["**/.git/**", "**/.cycode/**"]) + files_to_scan = get_relevant_files_in_path(path=path, exclude_patterns=['**/.git/**', '**/.cycode/**']) files_to_scan = exclude_irrelevant_files(context, files_to_scan) logger.debug('Found all relevant files for scanning %s', {'path': path, 'file_to_scan_count': len(files_to_scan)}) return scan_disk_files(context, path, files_to_scan) -@click.command() +@click.command(short_help='Use this command to scan the content that was not committed yet') @click.argument('ignored_args', nargs=-1, type=click.UNPROCESSED) @click.pass_context def pre_commit_scan(context: click.Context, ignored_args: List[str]): - """Use this command to scan the content that was not committed yet""" scan_type = context.obj['scan_type'] progress_bar = context.obj['progress_bar'] @@ -218,11 +212,10 @@ def pre_commit_scan(context: click.Context, ignored_args: List[str]): return scan_documents(context, documents_to_scan, is_git_diff=True) -@click.command() -@click.argument("ignored_args", nargs=-1, type=click.UNPROCESSED) +@click.command(short_help='Use this command to scan commits on the server side before pushing them to the repository') +@click.argument('ignored_args', nargs=-1, type=click.UNPROCESSED) @click.pass_context def pre_receive_scan(context: click.Context, ignored_args: List[str]): - """Use this command to scan commits on the server side before pushing them to the repository""" try: scan_type = context.obj['scan_type'] if scan_type != consts.SECRET_SCAN_TYPE: @@ -230,8 +223,8 @@ def pre_receive_scan(context: click.Context, ignored_args: List[str]): if should_skip_pre_receive_scan(): logger.info( - "A scan has been skipped as per your request." - " Please note that this may leave your system vulnerable to secrets that have not been detected" + 'A scan has been skipped as per your request.' + ' Please note that this may leave your system vulnerable to secrets that have not been detected' ) return @@ -556,7 +549,7 @@ def perform_pre_scan_documents_actions( context: click.Context, scan_type: str, documents_to_scan: List[Document], is_git_diff: bool = False ) -> None: if scan_type == consts.SCA_SCAN_TYPE: - logger.debug(f'Perform pre scan document actions') + logger.debug('Perform pre scan document actions') sca_code_scanner.add_dependencies_tree_document(context, documents_to_scan, is_git_diff) @@ -610,7 +603,7 @@ def perform_scan_async( cycode_client: 'ScanClient', zipped_documents: InMemoryZip, scan_type: str, scan_parameters: dict ) -> ZippedFileScanResult: scan_async_result = cycode_client.zipped_file_scan_async(zipped_documents, scan_type, scan_parameters) - logger.debug("scan request has been triggered successfully, scan id: %s", scan_async_result.scan_id) + logger.debug('scan request has been triggered successfully, scan id: %s', scan_async_result.scan_id) return poll_scan_results(cycode_client, scan_async_result.scan_id) @@ -621,13 +614,13 @@ def perform_commit_range_scan_async( to_commit_zipped_documents: InMemoryZip, scan_type: str, scan_parameters: dict, - timeout: int = None, + timeout: Optional[int] = None, ) -> ZippedFileScanResult: scan_async_result = cycode_client.multiple_zipped_file_scan_async( from_commit_zipped_documents, to_commit_zipped_documents, scan_type, scan_parameters ) - logger.debug("scan request has been triggered successfully, scan id: %s", scan_async_result.scan_id) + logger.debug('scan request has been triggered successfully, scan id: %s', scan_async_result.scan_id) return poll_scan_results(cycode_client, scan_async_result.scan_id, timeout) @@ -649,7 +642,8 @@ def poll_scan_results( if scan_details.scan_status == consts.SCAN_STATUS_COMPLETED: return _get_scan_result(cycode_client, scan_id, scan_details) - elif scan_details.scan_status == consts.SCAN_STATUS_ERROR: + + if scan_details.scan_status == consts.SCAN_STATUS_ERROR: raise custom_exceptions.ScanAsyncError( f'Error occurred while trying to scan zip file. {scan_details.message}' ) @@ -723,13 +717,12 @@ def parse_pre_receive_input() -> str: pre_receive_input = sys.stdin.read().strip() if not pre_receive_input: raise ValueError( - "Pre receive input was not found. Make sure that you are using this command only in pre-receive hook" + 'Pre receive input was not found. Make sure that you are using this command only in pre-receive hook' ) # each line represents a branch update request, handle the first one only # TODO(MichalBor): support case of multiple update branch requests - branch_update_details = pre_receive_input.splitlines()[0] - return branch_update_details + return pre_receive_input.splitlines()[0] def calculate_pre_receive_commit_range(branch_update_details: str) -> Optional[str]: @@ -737,13 +730,13 @@ def calculate_pre_receive_commit_range(branch_update_details: str) -> Optional[s # branch is deleted, no need to perform scan if end_commit == consts.EMPTY_COMMIT_SHA: - return + return None start_commit = get_oldest_unupdated_commit_for_branch(end_commit) # no new commit to update found if not start_commit: - return + return None return f'{start_commit}~1...{end_commit}' @@ -769,7 +762,7 @@ def get_diff_file_path(file): def get_diff_file_content(file): - return file.diff.decode('utf-8', errors='replace') + return file.diff.decode('UTF-8', errors='replace') def should_process_git_object(obj, _: int) -> bool: @@ -782,10 +775,10 @@ def get_git_repository_tree_file_entries(path: str, branch: str): def get_default_scan_parameters(context: click.Context) -> dict: return { - "monitor": context.obj.get("monitor"), - "report": context.obj.get("report"), - "package_vulnerabilities": context.obj.get("package-vulnerabilities"), - "license_compliance": context.obj.get("license-compliance"), + 'monitor': context.obj.get('monitor'), + 'report': context.obj.get('report'), + 'package_vulnerabilities': context.obj.get('package-vulnerabilities'), + 'license_compliance': context.obj.get('license-compliance'), } @@ -839,9 +832,7 @@ def exclude_irrelevant_detections( ) -> List[Detection]: relevant_detections = _exclude_detections_by_exclusions_configuration(detections, scan_type) relevant_detections = _exclude_detections_by_scan_type(relevant_detections, scan_type, command_scan_type) - relevant_detections = _exclude_detections_by_severity(relevant_detections, scan_type, severity_threshold) - - return relevant_detections + return _exclude_detections_by_severity(relevant_detections, scan_type, severity_threshold) def _exclude_detections_by_severity( @@ -866,9 +857,12 @@ def _exclude_detections_by_scan_type( return exclude_detections_in_deleted_lines(detections) exclude_in_deleted_lines = configuration_manager.get_should_exclude_detections_in_deleted_lines(command_scan_type) - if command_scan_type in consts.COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES: - if scan_type == consts.SECRET_SCAN_TYPE and exclude_in_deleted_lines: - return exclude_detections_in_deleted_lines(detections) + if ( + command_scan_type in consts.COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES + and scan_type == consts.SECRET_SCAN_TYPE + and exclude_in_deleted_lines + ): + return exclude_detections_in_deleted_lines(detections) return detections @@ -984,10 +978,7 @@ def _is_path_configured_in_exclusions(scan_type: str, file_path: str) -> bool: exclusions_by_path = configuration_manager.get_exclusions_by_scan_type(scan_type).get( consts.EXCLUSIONS_BY_PATH_SECTION_NAME, [] ) - for exclusion_path in exclusions_by_path: - if is_sub_path(exclusion_path, file_path): - return True - return False + return any(is_sub_path(exclusion_path, file_path) for exclusion_path in exclusions_by_path) def _get_package_name(detection: Detection) -> str: @@ -1002,7 +993,7 @@ def _get_package_name(detection: Detection) -> str: def _is_file_relevant_for_sca_scan(filename: str) -> bool: - if any([sca_excluded_path in filename for sca_excluded_path in consts.SCA_EXCLUDED_PATHS]): + if any(sca_excluded_path in filename for sca_excluded_path in consts.SCA_EXCLUDED_PATHS): logger.debug("file is irrelevant because it is from node_modules's inner path, %s", {'filename': filename}) return False @@ -1011,23 +1002,23 @@ def _is_file_relevant_for_sca_scan(filename: str) -> bool: def _is_relevant_file_to_scan(scan_type: str, filename: str) -> bool: if _is_subpath_of_cycode_configuration_folder(filename): - logger.debug("file is irrelevant because it is in cycode configuration directory, %s", {'filename': filename}) + logger.debug('file is irrelevant because it is in cycode configuration directory, %s', {'filename': filename}) return False if _is_path_configured_in_exclusions(scan_type, filename): - logger.debug("file is irrelevant because the file path is in the ignore paths list, %s", {'filename': filename}) + logger.debug('file is irrelevant because the file path is in the ignore paths list, %s', {'filename': filename}) return False if not _is_file_extension_supported(scan_type, filename): - logger.debug("file is irrelevant because the file extension is not supported, %s", {'filename': filename}) + logger.debug('file is irrelevant because the file extension is not supported, %s', {'filename': filename}) return False if is_binary_file(filename): - logger.debug("file is irrelevant because it is binary file, %s", {'filename': filename}) + logger.debug('file is irrelevant because it is binary file, %s', {'filename': filename}) return False if scan_type != consts.SCA_SCAN_TYPE and _does_file_exceed_max_size_limit(filename): - logger.debug("file is irrelevant because its exceeded max size limit, %s", {'filename': filename}) + logger.debug('file is irrelevant because its exceeded max size limit, %s', {'filename': filename}) return False if scan_type == consts.SCA_SCAN_TYPE and not _is_file_relevant_for_sca_scan(filename): @@ -1039,26 +1030,26 @@ def _is_relevant_file_to_scan(scan_type: str, filename: str) -> bool: def _is_relevant_document_to_scan(scan_type: str, filename: str, content: str) -> bool: if _is_subpath_of_cycode_configuration_folder(filename): logger.debug( - "document is irrelevant because it is in cycode configuration directory, %s", {'filename': filename} + 'document is irrelevant because it is in cycode configuration directory, %s', {'filename': filename} ) return False if _is_path_configured_in_exclusions(scan_type, filename): logger.debug( - "document is irrelevant because the document path is in the ignore paths list, %s", {'filename': filename} + 'document is irrelevant because the document path is in the ignore paths list, %s', {'filename': filename} ) return False if not _is_file_extension_supported(scan_type, filename): - logger.debug("document is irrelevant because the file extension is not supported, %s", {'filename': filename}) + logger.debug('document is irrelevant because the file extension is not supported, %s', {'filename': filename}) return False if is_binary_content(content): - logger.debug("document is irrelevant because it is binary, %s", {'filename': filename}) + logger.debug('document is irrelevant because it is binary, %s', {'filename': filename}) return False if scan_type != consts.SCA_SCAN_TYPE and _does_document_exceed_max_size_limit(content): - logger.debug("document is irrelevant because its exceeded max size limit, %s", {'filename': filename}) + logger.debug('document is irrelevant because its exceeded max size limit, %s', {'filename': filename}) return False return True @@ -1071,7 +1062,8 @@ def _is_file_extension_supported(scan_type: str, filename: str) -> bool: filename.endswith(supported_file_extension) for supported_file_extension in consts.INFRA_CONFIGURATION_SCAN_SUPPORTED_FILES ) - elif scan_type == consts.SCA_SCAN_TYPE: + + if scan_type == consts.SCA_SCAN_TYPE: return any( filename.endswith(supported_file) for supported_file in consts.SCA_CONFIGURATION_SCAN_SUPPORTED_FILES ) @@ -1083,11 +1075,11 @@ def _is_file_extension_supported(scan_type: str, filename: str) -> bool: def _does_file_exceed_max_size_limit(filename: str) -> bool: - return consts.FILE_MAX_SIZE_LIMIT_IN_BYTES < get_file_size(filename) + return get_file_size(filename) > consts.FILE_MAX_SIZE_LIMIT_IN_BYTES def _does_document_exceed_max_size_limit(content: str) -> bool: - return consts.FILE_MAX_SIZE_LIMIT_IN_BYTES < get_content_size(content) + return get_content_size(content) > consts.FILE_MAX_SIZE_LIMIT_IN_BYTES def _get_document_by_file_name( @@ -1137,14 +1129,14 @@ def _handle_exception(context: click.Context, e: Exception, *, return_exception: soft_fail=True, code='zip_too_large_error', message='The path you attempted to scan exceeds the current maximum scanning size cap (10MB). ' - 'Please try ignoring irrelevant paths using the ‘cycode ignore --by-path’ command ' + 'Please try ignoring irrelevant paths using the `cycode ignore --by-path` command ' 'and execute the scan again', ), InvalidGitRepositoryError: CliError( soft_fail=False, code='invalid_git_error', message='The path you supplied does not correlate to a git repository. ' - 'Should you still wish to scan this path, use: ‘cycode scan path ’', + 'Should you still wish to scan this path, use: `cycode scan path `', ), } @@ -1158,7 +1150,7 @@ def _handle_exception(context: click.Context, e: Exception, *, return_exception: return error ConsolePrinter(context).print_error(error) - return + return None if return_exception: return CliError(code='unknown_error', message=str(e)) @@ -1279,14 +1271,14 @@ def _map_detections_per_file(detections: List[dict]) -> List[DetectionsPerFile]: detection['message'] = detection['correlation_message'] file_name = _get_file_name_from_detection(detection) if file_name is None: - logger.debug("file name is missing from detection with id %s", detection.get('id')) + logger.debug('file name is missing from detection with id %s', detection.get('id')) continue if detections_per_files.get(file_name) is None: detections_per_files[file_name] = [DetectionSchema().load(detection)] else: detections_per_files[file_name].append(DetectionSchema().load(detection)) except Exception as e: - logger.debug("Failed to parse detection: %s", str(e)) + logger.debug('Failed to parse detection: %s', str(e)) continue return [ diff --git a/cycode/cli/config.py b/cycode/cli/config.py index 9e023bfa..71f354ad 100644 --- a/cycode/cli/config.py +++ b/cycode/cli/config.py @@ -1,8 +1,7 @@ import os -from cycode.cli.utils.yaml_utils import read_file from cycode.cli.user_settings.configuration_manager import ConfigurationManager - +from cycode.cli.utils.yaml_utils import read_file relative_path = os.path.dirname(__file__) config_file_path = os.path.join(relative_path, 'config.yaml') @@ -11,4 +10,4 @@ # env vars CYCODE_CLIENT_ID_ENV_VAR_NAME = 'CYCODE_CLIENT_ID' -CYCODE_CLIENT_SECRET_ENV_VAR_NAME = 'CYCODE_CLIENT_SECRET' +CYCODE_CLIENT_SECRET_ENV_VAR_NAME = 'CYCODE_CLIENT_SECRET' # noqa: S105 diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index f7e4a834..eb3f2c00 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -2,10 +2,10 @@ PRE_RECEIVE_COMMAND_SCAN_TYPE = 'pre_receive' COMMIT_HISTORY_COMMAND_SCAN_TYPE = 'commit_history' -SECRET_SCAN_TYPE = 'secret' +SECRET_SCAN_TYPE = 'secret' # noqa: S105 INFRA_CONFIGURATION_SCAN_TYPE = 'iac' -SCA_SCAN_TYPE = "sca" -SAST_SCAN_TYPE = "sast" +SCA_SCAN_TYPE = 'sca' +SAST_SCAN_TYPE = 'sast' INFRA_CONFIGURATION_SCAN_SUPPORTED_FILES = ['.tf', '.tf.json', '.json', '.yaml', '.yml', 'dockerfile'] @@ -78,37 +78,37 @@ SCA_EXCLUDED_PATHS = ['node_modules'] PROJECT_FILES_BY_ECOSYSTEM_MAP = { - "crates": ["Cargo.lock", "Cargo.toml"], - "composer": ["composer.json", "composer.lock"], - "go": ["go.sum", "go.mod", "Gopkg.lock"], - "maven_pom": ["pom.xml"], - "maven_gradle": ["build.gradle", "build.gradle.kts", "gradle.lockfile"], - "npm": ["package.json", "package-lock.json", "yarn.lock", "npm-shrinkwrap.json", ".npmrc"], - "nuget": ["packages.config", "project.assets.json", "packages.lock.json", "nuget.config"], - "ruby_gems": ["Gemfile", "Gemfile.lock"], - "sbt": ["build.sbt", "build.scala", "build.sbt.lock"], - "pypi_poetry": ["pyproject.toml", "poetry.lock"], - "pypi_pipenv": ["Pipfile", "Pipfile.lock"], - "pypi_requirements": ["requirements.txt"], - "pypi_setup": ["setup.py"], + 'crates': ['Cargo.lock', 'Cargo.toml'], + 'composer': ['composer.json', 'composer.lock'], + 'go': ['go.sum', 'go.mod', 'Gopkg.lock'], + 'maven_pom': ['pom.xml'], + 'maven_gradle': ['build.gradle', 'build.gradle.kts', 'gradle.lockfile'], + 'npm': ['package.json', 'package-lock.json', 'yarn.lock', 'npm-shrinkwrap.json', '.npmrc'], + 'nuget': ['packages.config', 'project.assets.json', 'packages.lock.json', 'nuget.config'], + 'ruby_gems': ['Gemfile', 'Gemfile.lock'], + 'sbt': ['build.sbt', 'build.scala', 'build.sbt.lock'], + 'pypi_poetry': ['pyproject.toml', 'poetry.lock'], + 'pypi_pipenv': ['Pipfile', 'Pipfile.lock'], + 'pypi_requirements': ['requirements.txt'], + 'pypi_setup': ['setup.py'], } COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES = [SECRET_SCAN_TYPE, SCA_SCAN_TYPE] COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES = [PRE_RECEIVE_COMMAND_SCAN_TYPE, COMMIT_HISTORY_COMMAND_SCAN_TYPE] -DEFAULT_CYCODE_API_URL = "https://api.cycode.com" -DEFAULT_CYCODE_APP_URL = "https://app.cycode.com" +DEFAULT_CYCODE_API_URL = 'https://api.cycode.com' +DEFAULT_CYCODE_APP_URL = 'https://app.cycode.com' # env var names -CYCODE_API_URL_ENV_VAR_NAME = "CYCODE_API_URL" -CYCODE_APP_URL_ENV_VAR_NAME = "CYCODE_APP_URL" -TIMEOUT_ENV_VAR_NAME = "TIMEOUT" -CYCODE_CLI_REQUEST_TIMEOUT_ENV_VAR_NAME = "CYCODE_CLI_REQUEST_TIMEOUT" -LOGGING_LEVEL_ENV_VAR_NAME = "LOGGING_LEVEL" +CYCODE_API_URL_ENV_VAR_NAME = 'CYCODE_API_URL' +CYCODE_APP_URL_ENV_VAR_NAME = 'CYCODE_APP_URL' +TIMEOUT_ENV_VAR_NAME = 'TIMEOUT' +CYCODE_CLI_REQUEST_TIMEOUT_ENV_VAR_NAME = 'CYCODE_CLI_REQUEST_TIMEOUT' +LOGGING_LEVEL_ENV_VAR_NAME = 'LOGGING_LEVEL' # use only for dev envs locally -BATCH_SIZE_ENV_VAR_NAME = "BATCH_SIZE" -VERBOSE_ENV_VAR_NAME = "CYCODE_CLI_VERBOSE" +BATCH_SIZE_ENV_VAR_NAME = 'BATCH_SIZE' +VERBOSE_ENV_VAR_NAME = 'CYCODE_CLI_VERBOSE' CYCODE_CONFIGURATION_DIRECTORY: str = '.cycode' @@ -152,7 +152,7 @@ Cycode Secrets Push Protection ------------------------------------------------------------------------------ Resolve the following secrets by rewriting your local commit history before pushing again. -Learn how to: https://cycode.com/dont-let-hardcoded-secrets-compromise-your-security-4-effective-remediation-techniques +Learn how to: https://cycode.com/dont-let-hardcoded-secrets-compromise-your-security-4-effective-remediation-techniques """ EXCLUDE_DETECTIONS_IN_DELETED_LINES_ENV_VAR_NAME = 'EXCLUDE_DETECTIONS_IN_DELETED_LINES' @@ -178,7 +178,8 @@ LICENSE_COMPLIANCE_POLICY_ID = '8f681450-49e1-4f7e-85b7-0c8fe84b3a35' PACKAGE_VULNERABILITY_POLICY_ID = '9369d10a-9ac0-48d3-9921-5de7fe9a37a7' -# Shortcut dependency paths by remove all middle depndencies between direct dependency and influence/vulnerable dependency. +# Shortcut dependency paths by remove all middle dependencies +# between direct dependency and influence/vulnerable dependency. # Example: A -> B -> C # Result: A -> ... -> C SCA_SHORTCUT_DEPENDENCY_PATHS = 2 diff --git a/cycode/cli/helpers/maven/base_restore_maven_dependencies.py b/cycode/cli/helpers/maven/base_restore_maven_dependencies.py index 42bd9cc7..b3c55008 100644 --- a/cycode/cli/helpers/maven/base_restore_maven_dependencies.py +++ b/cycode/cli/helpers/maven/base_restore_maven_dependencies.py @@ -1,10 +1,10 @@ from abc import ABC, abstractmethod -from typing import List, Optional, Dict +from typing import Dict, List, Optional import click from cycode.cli.models import Document -from cycode.cli.utils.path_utils import join_paths, get_file_dir +from cycode.cli.utils.path_utils import get_file_dir, join_paths from cycode.cli.utils.shell_executor import shell from cycode.cyclient import logger @@ -30,8 +30,7 @@ def __init__(self, context: click.Context, is_git_diff: bool, command_timeout: i self.command_timeout = command_timeout def restore(self, document: Document) -> Optional[Document]: - restore_dependencies_document = self.try_restore_dependencies(document) - return restore_dependencies_document + return self.try_restore_dependencies(document) def get_manifest_file_path(self, document: Document) -> str: return ( @@ -54,9 +53,8 @@ def get_lock_file_name(self) -> str: def try_restore_dependencies(self, document: Document) -> Optional[Document]: manifest_file_path = self.get_manifest_file_path(document) - document = Document( + return Document( build_dep_tree_path(document.path, self.get_lock_file_name()), execute_command(self.get_command(manifest_file_path), manifest_file_path, self.command_timeout), self.is_git_diff, ) - return document diff --git a/cycode/cli/helpers/maven/restore_maven_dependencies.py b/cycode/cli/helpers/maven/restore_maven_dependencies.py index 51e2aa96..bef8a1b1 100644 --- a/cycode/cli/helpers/maven/restore_maven_dependencies.py +++ b/cycode/cli/helpers/maven/restore_maven_dependencies.py @@ -9,7 +9,7 @@ execute_command, ) from cycode.cli.models import Document -from cycode.cli.utils.path_utils import get_file_dir, get_file_content, join_paths +from cycode.cli.utils.path_utils import get_file_content, get_file_dir, join_paths BUILD_MAVEN_FILE_NAME = 'pom.xml' MAVEN_CYCLONE_DEP_TREE_FILE_NAME = 'bom.json' diff --git a/cycode/cli/helpers/sca_code_scanner.py b/cycode/cli/helpers/sca_code_scanner.py index cbd040c7..87e301e3 100644 --- a/cycode/cli/helpers/sca_code_scanner.py +++ b/cycode/cli/helpers/sca_code_scanner.py @@ -1,14 +1,14 @@ import os -from typing import List, Optional, Dict +from typing import Dict, List, Optional import click -from git import Repo, GitCommandError +from git import GitCommandError, Repo -from cycode.cli.consts import * +from cycode.cli import consts from cycode.cli.helpers.maven.restore_gradle_dependencies import RestoreGradleDependencies from cycode.cli.helpers.maven.restore_maven_dependencies import RestoreMavenDependencies from cycode.cli.models import Document -from cycode.cli.utils.path_utils import get_file_dir, join_paths, get_file_content +from cycode.cli.utils.path_utils import get_file_content, get_file_dir, join_paths from cycode.cyclient import logger BUILD_GRADLE_FILE_NAME = 'build.gradle' @@ -33,7 +33,7 @@ def perform_pre_hook_range_scan_actions( git_head_documents: List[Document], pre_committed_documents: List[Document] ) -> None: repo = Repo(os.getcwd()) - add_ecosystem_related_files_if_exists(git_head_documents, repo, GIT_HEAD_COMMIT_REV) + add_ecosystem_related_files_if_exists(git_head_documents, repo, consts.GIT_HEAD_COMMIT_REV) add_ecosystem_related_files_if_exists(pre_committed_documents) @@ -43,7 +43,7 @@ def add_ecosystem_related_files_if_exists( for doc in documents: ecosystem = get_project_file_ecosystem(doc) if ecosystem is None: - logger.debug("failed to resolve project file ecosystem: %s", doc.path) + logger.debug('failed to resolve project file ecosystem: %s', doc.path) continue documents_to_add = get_doc_ecosystem_related_project_files(doc, documents, ecosystem, commit_rev, repo) documents.extend(documents_to_add) @@ -53,7 +53,7 @@ def get_doc_ecosystem_related_project_files( doc: Document, documents: List[Document], ecosystem: str, commit_rev: Optional[str], repo: Optional[Repo] ) -> List[Document]: documents_to_add: List[Document] = [] - for ecosystem_project_file in PROJECT_FILES_BY_ECOSYSTEM_MAP.get(ecosystem): + for ecosystem_project_file in consts.PROJECT_FILES_BY_ECOSYSTEM_MAP.get(ecosystem): file_to_search = join_paths(get_file_dir(doc.path), ecosystem_project_file) if not is_project_file_exists_in_documents(documents, file_to_search): file_content = ( @@ -73,7 +73,7 @@ def is_project_file_exists_in_documents(documents: List[Document], file: str) -> def get_project_file_ecosystem(document: Document) -> Optional[str]: - for ecosystem, project_files in PROJECT_FILES_BY_ECOSYSTEM_MAP.items(): + for ecosystem, project_files in consts.PROJECT_FILES_BY_ECOSYSTEM_MAP.items(): for project_file in project_files: if document.path.endswith(project_file): return ecosystem @@ -96,10 +96,10 @@ def try_restore_dependencies( is_monitor_action = context.obj.get('monitor') project_path = context.params.get('path') manifest_file_path = get_manifest_file_path(document, is_monitor_action, project_path) - logger.debug(f"Succeeded to generate dependencies tree on path: {manifest_file_path}") + logger.debug(f'Succeeded to generate dependencies tree on path: {manifest_file_path}') if restore_dependencies_document.path in documents_to_add: - logger.debug(f"Duplicate document on restore for path: {restore_dependencies_document.path}") + logger.debug(f'Duplicate document on restore for path: {restore_dependencies_document.path}') else: documents_to_add[restore_dependencies_document.path] = restore_dependencies_document diff --git a/cycode/cli/main.py b/cycode/cli/main.py index 946c1c8b..cb741ad7 100644 --- a/cycode/cli/main.py +++ b/cycode/cli/main.py @@ -1,23 +1,22 @@ import logging - -import click import sys +from typing import TYPE_CHECKING, List, Optional -from typing import List, Optional, TYPE_CHECKING +import click from cycode import __version__ -from cycode.cli.consts import NO_ISSUES_STATUS_CODE, ISSUE_DETECTED_STATUS_CODE -from cycode.cli.models import Severity -from cycode.cli.config import config from cycode.cli import code_scanner -from cycode.cli.user_settings.credentials_manager import CredentialsManager -from cycode.cli.user_settings.configuration_manager import ConfigurationManager -from cycode.cli.user_settings.user_settings_commands import set_credentials, add_exclusions from cycode.cli.auth.auth_command import authenticate +from cycode.cli.config import config +from cycode.cli.consts import ISSUE_DETECTED_STATUS_CODE, NO_ISSUES_STATUS_CODE +from cycode.cli.models import Severity +from cycode.cli.user_settings.configuration_manager import ConfigurationManager +from cycode.cli.user_settings.credentials_manager import CredentialsManager +from cycode.cli.user_settings.user_settings_commands import add_exclusions, set_credentials from cycode.cli.utils import scan_utils from cycode.cli.utils.progress_bar import get_progress_bar -from cycode.cyclient import logger from cycode.cli.utils.progress_bar import logger as progress_bar_logger +from cycode.cyclient import logger from cycode.cyclient.cycode_client_base import CycodeClientBase from cycode.cyclient.models import UserAgentOptionScheme from cycode.cyclient.scan_config.scan_config_creator import create_scan_client @@ -25,25 +24,27 @@ if TYPE_CHECKING: from cycode.cyclient.scan_client import ScanClient -CONTEXT = dict() +CONTEXT = {} @click.group( commands={ - "repository": code_scanner.scan_repository, - "commit_history": code_scanner.scan_repository_commit_history, - "path": code_scanner.scan_path, - "pre_commit": code_scanner.pre_commit_scan, - "pre_receive": code_scanner.pre_receive_scan, + 'repository': code_scanner.scan_repository, + 'commit_history': code_scanner.scan_repository_commit_history, + 'path': code_scanner.scan_path, + 'pre_commit': code_scanner.pre_commit_scan, + 'pre_receive': code_scanner.pre_receive_scan, }, + short_help='Scan content for secrets/IaC/sca/SAST violations. ' + 'You need to specify which scan type: ci/commit_history/path/repository/etc', ) @click.option( '--scan-type', '-t', - default="secret", + default='secret', help=""" \b - Specify the scan you wish to execute (secret/iac/sca), + Specify the scan you wish to execute (secret/iac/sca), the default is secret """, type=click.Choice(config['scans']['supported_scans']), @@ -78,7 +79,7 @@ default=None, help=""" \b - Specify the results output (text/json/table), + Specify the results output (text/json/table), the default is text """, type=click.Choice(['text', 'json', 'table']), @@ -93,7 +94,7 @@ @click.option( '--sca-scan', default=None, - help="Specify the sca scan you wish to execute (package-vulnerabilities/license-compliance), the default is both", + help='Specify the sca scan you wish to execute (package-vulnerabilities/license-compliance), the default is both', multiple=True, type=click.Choice(config['scans']['supported_sca_scans']), ) @@ -101,7 +102,9 @@ '--monitor', is_flag=True, default=False, - help="When specified, the scan results will be recorded in the knowledge graph. Please note that when working in 'monitor' mode, the knowledge graph will not be updated as a result of SCM events (Push, Repo creation).(supported for SCA scan type only).", + help="When specified, the scan results will be recorded in the knowledge graph. " + "Please note that when working in 'monitor' mode, the knowledge graph " + "will not be updated as a result of SCM events (Push, Repo creation).(supported for SCA scan type only).", type=bool, required=False, ) @@ -109,7 +112,8 @@ '--report', is_flag=True, default=False, - help="When specified, a violations report will be generated. A URL link to the report will be printed as an output to the command execution", + help='When specified, a violations report will be generated. ' + 'A URL link to the report will be printed as an output to the command execution', type=bool, required=False, ) @@ -127,34 +131,33 @@ def code_scan( monitor, report, ): - """Scan content for secrets/IaC/sca/SAST violations, You need to specify which scan type: ci/commit_history/path/repository/etc""" if show_secret: - context.obj["show_secret"] = show_secret + context.obj['show_secret'] = show_secret else: - context.obj["show_secret"] = config["result_printer"]["default"]["show_secret"] + context.obj['show_secret'] = config['result_printer']['default']['show_secret'] if soft_fail: - context.obj["soft_fail"] = soft_fail + context.obj['soft_fail'] = soft_fail else: - context.obj["soft_fail"] = config["soft_fail"] + context.obj['soft_fail'] = config['soft_fail'] - context.obj["scan_type"] = scan_type + context.obj['scan_type'] = scan_type # save backward compatability with old style command if output is not None: - context.obj["output"] = output - if output == "json": - context.obj["no_progress_meter"] = True + context.obj['output'] = output + if output == 'json': + context.obj['no_progress_meter'] = True - context.obj["client"] = get_cycode_client(client_id, secret) - context.obj["severity_threshold"] = severity_threshold - context.obj["monitor"] = monitor - context.obj["report"] = report + context.obj['client'] = get_cycode_client(client_id, secret) + context.obj['severity_threshold'] = severity_threshold + context.obj['monitor'] = monitor + context.obj['report'] = report _sca_scan_to_context(context, sca_scan) - context.obj["progress_bar"] = get_progress_bar(hidden=context.obj["no_progress_meter"]) - context.obj["progress_bar"].start() + context.obj['progress_bar'] = get_progress_bar(hidden=context.obj['no_progress_meter']) + context.obj['progress_bar'].start() return 1 @@ -177,15 +180,15 @@ def finalize(context: click.Context, *_, **__): @click.group( - commands={"scan": code_scan, "configure": set_credentials, "ignore": add_exclusions, "auth": authenticate}, + commands={'scan': code_scan, 'configure': set_credentials, 'ignore': add_exclusions, 'auth': authenticate}, context_settings=CONTEXT, ) @click.option( - "--verbose", - "-v", + '--verbose', + '-v', is_flag=True, default=False, - help="Show detailed logs", + help='Show detailed logs', ) @click.option( '--no-progress-meter', @@ -205,7 +208,7 @@ def finalize(context: click.Context, *_, **__): help='Characteristic JSON object that lets servers identify the application', type=str, ) -@click.version_option(__version__, prog_name="cycode") +@click.version_option(__version__, prog_name='cycode') @click.pass_context def main_cli(context: click.Context, verbose: bool, no_progress_meter: bool, output: str, user_agent: Optional[str]): context.ensure_object(dict) @@ -233,9 +236,9 @@ def get_cycode_client(client_id: str, client_secret: str) -> 'ScanClient': if not client_id or not client_secret: client_id, client_secret = _get_configured_credentials() if not client_id: - raise click.ClickException("Cycode client id needed.") + raise click.ClickException('Cycode client id needed.') if not client_secret: - raise click.ClickException("Cycode client secret is needed.") + raise click.ClickException('Cycode client secret is needed.') return create_scan_client(client_id, client_secret) diff --git a/cycode/cli/models.py b/cycode/cli/models.py index 051b2fa7..67453b8c 100644 --- a/cycode/cli/models.py +++ b/cycode/cli/models.py @@ -1,18 +1,18 @@ from enum import Enum -from typing import List, NamedTuple, Dict, Type, Optional +from typing import Dict, List, NamedTuple, Optional, Type from cycode.cyclient.models import Detection class Document: - def __init__(self, path: str, content: str, is_git_diff_format: bool = False, unique_id: str = None): + def __init__(self, path: str, content: str, is_git_diff_format: bool = False, unique_id: Optional[str] = None): self.path = path self.content = content self.is_git_diff_format = is_git_diff_format self.unique_id = unique_id def __repr__(self) -> str: - return "path:{0}, " "content:{1}".format(self.path, self.content) + return 'path:{0}, content:{1}'.format(self.path, self.content) class DocumentDetections: @@ -21,14 +21,14 @@ def __init__(self, document: Document, detections: List[Detection]): self.detections = detections def __repr__(self) -> str: - return "document:{0}, " "detections:{1}".format(self.document, self.detections) + return 'document:{0}, detections:{1}'.format(self.document, self.detections) class Severity(Enum): INFO = -1 LOW = 0 MEDIUM = 1 - MODERATE = 1 + MODERATE = 1 # noqa: PIE796. TODO(MarshalX): rework. should not be Enum HIGH = 2 CRITICAL = 3 diff --git a/cycode/cli/printers/base_printer.py b/cycode/cli/printers/base_printer.py index afd46513..7b094c65 100644 --- a/cycode/cli/printers/base_printer.py +++ b/cycode/cli/printers/base_printer.py @@ -1,9 +1,9 @@ from abc import ABC, abstractmethod -from typing import List, TYPE_CHECKING +from typing import TYPE_CHECKING, List import click -from cycode.cli.models import CliResult, CliError +from cycode.cli.models import CliError, CliResult if TYPE_CHECKING: from cycode.cli.models import LocalScanResult diff --git a/cycode/cli/printers/base_table_printer.py b/cycode/cli/printers/base_table_printer.py index 4a956e66..56a1a552 100644 --- a/cycode/cli/printers/base_table_printer.py +++ b/cycode/cli/printers/base_table_printer.py @@ -1,11 +1,11 @@ import abc -from typing import List, TYPE_CHECKING +from typing import TYPE_CHECKING, List import click -from cycode.cli.printers.text_printer import TextPrinter from cycode.cli.models import CliError, CliResult from cycode.cli.printers.base_printer import BasePrinter +from cycode.cli.printers.text_printer import TextPrinter if TYPE_CHECKING: from cycode.cli.models import LocalScanResult diff --git a/cycode/cli/printers/console_printer.py b/cycode/cli/printers/console_printer.py index 06e3ddf7..2321f089 100644 --- a/cycode/cli/printers/console_printer.py +++ b/cycode/cli/printers/console_printer.py @@ -1,11 +1,12 @@ +from typing import TYPE_CHECKING, ClassVar, Dict, List + import click -from typing import List, TYPE_CHECKING from cycode.cli.exceptions.custom_exceptions import CycodeError -from cycode.cli.models import CliResult, CliError -from cycode.cli.printers.table_printer import TablePrinter -from cycode.cli.printers.sca_table_printer import SCATablePrinter +from cycode.cli.models import CliError, CliResult from cycode.cli.printers.json_printer import JsonPrinter +from cycode.cli.printers.sca_table_printer import SCATablePrinter +from cycode.cli.printers.table_printer import TablePrinter from cycode.cli.printers.text_printer import TextPrinter if TYPE_CHECKING: @@ -14,7 +15,7 @@ class ConsolePrinter: - _AVAILABLE_PRINTERS = { + _AVAILABLE_PRINTERS: ClassVar[Dict[str, 'BasePrinter']] = { 'text': TextPrinter, 'json': JsonPrinter, 'table': TablePrinter, diff --git a/cycode/cli/printers/json_printer.py b/cycode/cli/printers/json_printer.py index 6469100c..52df39ea 100644 --- a/cycode/cli/printers/json_printer.py +++ b/cycode/cli/printers/json_printer.py @@ -1,9 +1,9 @@ import json -from typing import List, TYPE_CHECKING +from typing import TYPE_CHECKING, List import click -from cycode.cli.models import CliResult, CliError +from cycode.cli.models import CliError, CliResult from cycode.cli.printers.base_printer import BasePrinter from cycode.cyclient.models import DetectionSchema diff --git a/cycode/cli/printers/sca_table_printer.py b/cycode/cli/printers/sca_table_printer.py index a8372873..b3b03567 100644 --- a/cycode/cli/printers/sca_table_printer.py +++ b/cycode/cli/printers/sca_table_printer.py @@ -1,5 +1,5 @@ from collections import defaultdict -from typing import List, Dict, TYPE_CHECKING +from typing import TYPE_CHECKING, Dict, List import click from texttable import Texttable @@ -59,7 +59,7 @@ def _print_detection_per_detection_type_id( if detection_type_id == PACKAGE_VULNERABILITY_POLICY_ID: title = 'Dependencies Vulnerabilities' - headers = [SEVERITY_COLUMN] + headers + headers = [SEVERITY_COLUMN, *headers] headers.extend(PREVIEW_DETECTIONS_COMMON_HEADERS) headers.append(CVE_COLUMN) headers.append(UPGRADE_COLUMN) @@ -129,7 +129,7 @@ def _get_common_detection_fields(self, detection: Detection) -> List[str]: ] if self._is_git_repository(): - row = [detection.detection_details.get('repository_name')] + row + row = [detection.detection_details.get('repository_name'), *row] return row diff --git a/cycode/cli/printers/table.py b/cycode/cli/printers/table.py index 3677ec05..d2847399 100644 --- a/cycode/cli/printers/table.py +++ b/cycode/cli/printers/table.py @@ -1,4 +1,5 @@ -from typing import List, Dict, Optional, TYPE_CHECKING +from typing import TYPE_CHECKING, Dict, List, Optional + from texttable import Texttable if TYPE_CHECKING: @@ -11,12 +12,12 @@ class Table: def __init__(self, column_infos: Optional[List['ColumnInfo']] = None): self._column_widths = None - self._columns: Dict['ColumnInfo', List[str]] = dict() + self._columns: Dict['ColumnInfo', List[str]] = {} if column_infos: - self._columns: Dict['ColumnInfo', List[str]] = {columns: list() for columns in column_infos} + self._columns: Dict['ColumnInfo', List[str]] = {columns: [] for columns in column_infos} def add(self, column: 'ColumnInfo') -> None: - self._columns[column] = list() + self._columns[column] = [] def set(self, column: 'ColumnInfo', value: str) -> None: # we push values only for existing columns what were added before diff --git a/cycode/cli/printers/table_models.py b/cycode/cli/printers/table_models.py index 34859e06..e3dc195d 100644 --- a/cycode/cli/printers/table_models.py +++ b/cycode/cli/printers/table_models.py @@ -1,4 +1,4 @@ -from typing import NamedTuple, Dict +from typing import Dict, NamedTuple class ColumnInfoBuilder: diff --git a/cycode/cli/printers/table_printer.py b/cycode/cli/printers/table_printer.py index 3e5e6d49..c5a01201 100644 --- a/cycode/cli/printers/table_printer.py +++ b/cycode/cli/printers/table_printer.py @@ -1,13 +1,13 @@ -from typing import List, TYPE_CHECKING +from typing import TYPE_CHECKING, List import click +from cycode.cli.consts import INFRA_CONFIGURATION_SCAN_TYPE, SAST_SCAN_TYPE, SECRET_SCAN_TYPE +from cycode.cli.models import Detection, Document from cycode.cli.printers.base_table_printer import BaseTablePrinter -from cycode.cli.printers.table_models import ColumnInfoBuilder, ColumnWidthsConfig from cycode.cli.printers.table import Table -from cycode.cli.utils.string_utils import obfuscate_text, get_position_in_line -from cycode.cli.consts import SECRET_SCAN_TYPE, INFRA_CONFIGURATION_SCAN_TYPE, SAST_SCAN_TYPE -from cycode.cli.models import Detection, Document +from cycode.cli.printers.table_models import ColumnInfoBuilder, ColumnWidthsConfig +from cycode.cli.utils.string_utils import get_position_in_line, obfuscate_text if TYPE_CHECKING: from cycode.cli.models import LocalScanResult diff --git a/cycode/cli/printers/text_printer.py b/cycode/cli/printers/text_printer.py index de71371d..4555225d 100644 --- a/cycode/cli/printers/text_printer.py +++ b/cycode/cli/printers/text_printer.py @@ -1,13 +1,13 @@ import math -from typing import List, Optional, TYPE_CHECKING +from typing import TYPE_CHECKING, List, Optional import click -from cycode.cli.printers.base_printer import BasePrinter -from cycode.cli.models import DocumentDetections, Detection, Document, CliResult, CliError from cycode.cli.config import config -from cycode.cli.consts import SECRET_SCAN_TYPE, COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES -from cycode.cli.utils.string_utils import obfuscate_text, get_position_in_line +from cycode.cli.consts import COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES, SECRET_SCAN_TYPE +from cycode.cli.models import CliError, CliResult, Detection, Document, DocumentDetections +from cycode.cli.printers.base_printer import BasePrinter +from cycode.cli.utils.string_utils import get_position_in_line, obfuscate_text if TYPE_CHECKING: from cycode.cli.models import LocalScanResult @@ -98,11 +98,12 @@ def _print_line_of_code_segment( def _print_detection_line( self, document: Document, line: str, line_number: int, detection_position_in_line: int, violation_length: int ) -> None: - click.echo( - f'{self._get_line_number_style(line_number)} ' - f'{self._get_detection_line_style(line, document.is_git_diff_format, detection_position_in_line, violation_length)}' + detection_line = self._get_detection_line_style( + line, document.is_git_diff_format, detection_position_in_line, violation_length ) + click.echo(f'{self._get_line_number_style(line_number)} {detection_line}') + def _print_line(self, document: Document, line: str, line_number: int): line_no = self._get_line_number_style(line_number) line = self._get_line_style(line, document.is_git_diff_format) diff --git a/cycode/cli/user_settings/base_file_manager.py b/cycode/cli/user_settings/base_file_manager.py index ec7c4813..a640d8fa 100644 --- a/cycode/cli/user_settings/base_file_manager.py +++ b/cycode/cli/user_settings/base_file_manager.py @@ -1,6 +1,7 @@ import os from abc import ABC, abstractmethod -from cycode.cli.utils.yaml_utils import update_file, read_file + +from cycode.cli.utils.yaml_utils import read_file, update_file class BaseFileManager(ABC): diff --git a/cycode/cli/user_settings/config_file_manager.py b/cycode/cli/user_settings/config_file_manager.py index 876f85f1..5d2e3016 100644 --- a/cycode/cli/user_settings/config_file_manager.py +++ b/cycode/cli/user_settings/config_file_manager.py @@ -1,8 +1,8 @@ import os -from typing import Optional, List, Dict +from typing import Dict, List, Optional -from cycode.cli.user_settings.base_file_manager import BaseFileManager from cycode.cli.consts import CYCODE_CONFIGURATION_DIRECTORY +from cycode.cli.user_settings.base_file_manager import BaseFileManager class ConfigFileManager(BaseFileManager): @@ -36,8 +36,7 @@ def get_verbose_flag(self) -> Optional[bool]: def get_exclusions_by_scan_type(self, scan_type) -> Dict: exclusions_section = self._get_section(self.EXCLUSIONS_SECTION_NAME) - scan_type_exclusions = exclusions_section.get(scan_type, {}) - return scan_type_exclusions + return exclusions_section.get(scan_type, {}) def get_max_commits(self, command_scan_type) -> Optional[int]: return self._get_value_from_command_scan_type_configuration(command_scan_type, self.MAX_COMMITS_FIELD_NAME) @@ -87,8 +86,7 @@ def _get_exclusions_by_exclusion_type(self, scan_type, exclusion_type) -> List: def _get_value_from_environment_section(self, field_name: str): environment_section = self._get_section(self.ENVIRONMENT_SECTION_NAME) - value = environment_section.get(field_name) - return value + return environment_section.get(field_name) def _get_scan_configuration_by_scan_type(self, command_scan_type: str) -> Dict: scan_section = self._get_section(self.SCAN_SECTION_NAME) @@ -96,8 +94,7 @@ def _get_scan_configuration_by_scan_type(self, command_scan_type: str) -> Dict: def _get_value_from_command_scan_type_configuration(self, command_scan_type: str, field_name: str): command_scan_type_configuration = self._get_scan_configuration_by_scan_type(command_scan_type) - value = command_scan_type_configuration.get(field_name) - return value + return command_scan_type_configuration.get(field_name) def _get_section(self, section_name: str) -> Dict: file_content = self.read_file() diff --git a/cycode/cli/user_settings/configuration_manager.py b/cycode/cli/user_settings/configuration_manager.py index fa11c87a..f93759c9 100644 --- a/cycode/cli/user_settings/configuration_manager.py +++ b/cycode/cli/user_settings/configuration_manager.py @@ -1,10 +1,10 @@ import os from pathlib import Path -from typing import Optional, Dict +from typing import Dict, Optional from uuid import uuid4 +from cycode.cli import consts from cycode.cli.user_settings.config_file_manager import ConfigFileManager -from cycode.cli.consts import * class ConfigurationManager: @@ -28,7 +28,7 @@ def get_cycode_api_url(self) -> str: if api_url is not None: return api_url - return DEFAULT_CYCODE_API_URL + return consts.DEFAULT_CYCODE_API_URL def get_cycode_app_url(self) -> str: app_url = self.get_app_url_from_environment_variables() @@ -43,7 +43,7 @@ def get_cycode_app_url(self) -> str: if app_url is not None: return app_url - return DEFAULT_CYCODE_APP_URL + return consts.DEFAULT_CYCODE_APP_URL def get_verbose_flag(self) -> bool: verbose_flag_env_var = self.get_verbose_flag_from_environment_variables() @@ -52,13 +52,13 @@ def get_verbose_flag(self) -> bool: return verbose_flag_env_var or verbose_flag_local_config or verbose_flag_global_config def get_api_url_from_environment_variables(self) -> Optional[str]: - return self._get_value_from_environment_variables(CYCODE_API_URL_ENV_VAR_NAME) + return self._get_value_from_environment_variables(consts.CYCODE_API_URL_ENV_VAR_NAME) def get_app_url_from_environment_variables(self) -> Optional[str]: - return self._get_value_from_environment_variables(CYCODE_APP_URL_ENV_VAR_NAME) + return self._get_value_from_environment_variables(consts.CYCODE_APP_URL_ENV_VAR_NAME) def get_verbose_flag_from_environment_variables(self) -> bool: - value = self._get_value_from_environment_variables(VERBOSE_ENV_VAR_NAME, '') + value = self._get_value_from_environment_variables(consts.VERBOSE_ENV_VAR_NAME, '') return value.lower() in ('true', '1') def get_exclusions_by_scan_type(self, scan_type) -> Dict: @@ -97,19 +97,21 @@ def get_config_file_manager(self, scope: Optional[str] = None) -> ConfigFileMana def get_scan_polling_timeout_in_seconds(self) -> int: return int( self._get_value_from_environment_variables( - SCAN_POLLING_TIMEOUT_IN_SECONDS_ENV_VAR_NAME, DEFAULT_SCAN_POLLING_TIMEOUT_IN_SECONDS + consts.SCAN_POLLING_TIMEOUT_IN_SECONDS_ENV_VAR_NAME, consts.DEFAULT_SCAN_POLLING_TIMEOUT_IN_SECONDS ) ) def get_sca_pre_commit_timeout_in_seconds(self) -> int: return int( self._get_value_from_environment_variables( - SCA_PRE_COMMIT_TIMEOUT_IN_SECONDS_ENV_VAR_NAME, DEFAULT_SCA_PRE_COMMIT_TIMEOUT_IN_SECONDS + consts.SCA_PRE_COMMIT_TIMEOUT_IN_SECONDS_ENV_VAR_NAME, consts.DEFAULT_SCA_PRE_COMMIT_TIMEOUT_IN_SECONDS ) ) def get_pre_receive_max_commits_to_scan_count(self, command_scan_type: str) -> int: - max_commits = self._get_value_from_environment_variables(PRE_RECEIVE_MAX_COMMITS_TO_SCAN_COUNT_ENV_VAR_NAME) + max_commits = self._get_value_from_environment_variables( + consts.PRE_RECEIVE_MAX_COMMITS_TO_SCAN_COUNT_ENV_VAR_NAME + ) if max_commits is not None: return int(max_commits) @@ -121,10 +123,10 @@ def get_pre_receive_max_commits_to_scan_count(self, command_scan_type: str) -> i if max_commits is not None: return max_commits - return DEFAULT_PRE_RECEIVE_MAX_COMMITS_TO_SCAN_COUNT + return consts.DEFAULT_PRE_RECEIVE_MAX_COMMITS_TO_SCAN_COUNT def get_pre_receive_command_timeout(self, command_scan_type: str) -> int: - command_timeout = self._get_value_from_environment_variables(PRE_RECEIVE_COMMAND_TIMEOUT_ENV_VAR_NAME) + command_timeout = self._get_value_from_environment_variables(consts.PRE_RECEIVE_COMMAND_TIMEOUT_ENV_VAR_NAME) if command_timeout is not None: return int(command_timeout) @@ -136,11 +138,11 @@ def get_pre_receive_command_timeout(self, command_scan_type: str) -> int: if command_timeout is not None: return command_timeout - return DEFAULT_PRE_RECEIVE_COMMAND_TIMEOUT_IN_SECONDS + return consts.DEFAULT_PRE_RECEIVE_COMMAND_TIMEOUT_IN_SECONDS def get_should_exclude_detections_in_deleted_lines(self, command_scan_type: str) -> bool: exclude_detections_in_deleted_lines = self._get_value_from_environment_variables( - EXCLUDE_DETECTIONS_IN_DELETED_LINES_ENV_VAR_NAME + consts.EXCLUDE_DETECTIONS_IN_DELETED_LINES_ENV_VAR_NAME ) if exclude_detections_in_deleted_lines is not None: return exclude_detections_in_deleted_lines.lower() in ('true', '1') @@ -157,7 +159,7 @@ def get_should_exclude_detections_in_deleted_lines(self, command_scan_type: str) if exclude_detections_in_deleted_lines is not None: return exclude_detections_in_deleted_lines - return DEFAULT_EXCLUDE_DETECTIONS_IN_DELETED_LINES + return consts.DEFAULT_EXCLUDE_DETECTIONS_IN_DELETED_LINES @staticmethod def _get_value_from_environment_variables(env_var_name, default=None): diff --git a/cycode/cli/user_settings/credentials_manager.py b/cycode/cli/user_settings/credentials_manager.py index 675deae2..0ec0e6e8 100644 --- a/cycode/cli/user_settings/credentials_manager.py +++ b/cycode/cli/user_settings/credentials_manager.py @@ -1,9 +1,9 @@ import os from pathlib import Path -from cycode.cli.utils.yaml_utils import read_file from cycode.cli.config import CYCODE_CLIENT_ID_ENV_VAR_NAME, CYCODE_CLIENT_SECRET_ENV_VAR_NAME from cycode.cli.user_settings.base_file_manager import BaseFileManager +from cycode.cli.utils.yaml_utils import read_file class CredentialsManager(BaseFileManager): @@ -39,7 +39,7 @@ def get_credentials_from_file(self) -> (str, str): def update_credentials_file(self, client_id: str, client_secret: str): credentials = {self.CLIENT_ID_FIELD_NAME: client_id, self.CLIENT_SECRET_FIELD_NAME: client_secret} - filename = self.get_filename() + self.get_filename() self.write_content_to_file(credentials) def get_filename(self) -> str: diff --git a/cycode/cli/user_settings/user_settings_commands.py b/cycode/cli/user_settings/user_settings_commands.py index a7863671..f4fb5bee 100644 --- a/cycode/cli/user_settings/user_settings_commands.py +++ b/cycode/cli/user_settings/user_settings_commands.py @@ -1,14 +1,14 @@ -import re import os.path +import re from typing import Optional import click -from cycode.cli.utils.string_utils import obfuscate_text, hash_string_to_sha256 -from cycode.cli.utils.path_utils import get_absolute_path +from cycode.cli import consts +from cycode.cli.config import config, configuration_manager from cycode.cli.user_settings.credentials_manager import CredentialsManager -from cycode.cli.config import configuration_manager, config -from cycode.cli.consts import * +from cycode.cli.utils.path_utils import get_absolute_path +from cycode.cli.utils.string_utils import hash_string_to_sha256, obfuscate_text from cycode.cyclient import logger CREDENTIALS_UPDATED_SUCCESSFULLY_MESSAGE = 'Successfully configured CLI credentials!' @@ -21,9 +21,10 @@ credentials_manager = CredentialsManager() -@click.command() +@click.command( + short_help='Initial command to authenticate your CLI client with Cycode using client ID and client secret' +) def set_credentials(): - """Initial command to authenticate your CLI client with Cycode using client ID and client secret""" click.echo(f'Update credentials in file ({credentials_manager.get_filename()})') current_client_id, current_client_secret = credentials_manager.get_credentials_from_file() client_id = _get_client_id_input(current_client_id) @@ -38,25 +39,25 @@ def set_credentials(): @click.command() @click.option( - "--by-value", type=click.STRING, required=False, help="Ignore a specific value while scanning for secrets" + '--by-value', type=click.STRING, required=False, help='Ignore a specific value while scanning for secrets' ) @click.option( - "--by-sha", + '--by-sha', type=click.STRING, required=False, help='Ignore a specific SHA512 representation of a string while scanning for secrets', ) @click.option( - "--by-path", type=click.STRING, required=False, help='Avoid scanning a specific path. Need to specify scan type ' + '--by-path', type=click.STRING, required=False, help='Avoid scanning a specific path. Need to specify scan type ' ) @click.option( - "--by-rule", + '--by-rule', type=click.STRING, required=False, help='Ignore scanning a specific secret rule ID/IaC rule ID. Need to specify scan type.', ) @click.option( - "--by-package", + '--by-package', type=click.STRING, required=False, help='Ignore scanning a specific package version while running SCA scan. expected pattern - name@version', @@ -67,7 +68,7 @@ def set_credentials(): default='secret', help=""" \b - Specify the scan you wish to execute (secrets/iac), + Specify the scan you wish to execute (secrets/iac), the default is secrets """, type=click.Choice(config['scans']['supported_scans']), @@ -87,33 +88,32 @@ def add_exclusions( ): """Ignore a specific value, path or rule ID""" 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") + raise click.ClickException('ignore by type is missing') + + if any(by is not None for by in [by_value, by_sha]) and scan_type != consts.SECRET_SCAN_TYPE: + raise click.ClickException('this exclude is supported only for secret scan type') if by_value is not None: - if scan_type != SECRET_SCAN_TYPE: - raise click.ClickException("exclude by value is supported only for secret scan type") - exclusion_type = EXCLUSIONS_BY_VALUE_SECTION_NAME + exclusion_type = consts.EXCLUSIONS_BY_VALUE_SECTION_NAME exclusion_value = hash_string_to_sha256(by_value) elif by_sha is not None: - if scan_type != SECRET_SCAN_TYPE: - raise click.ClickException("exclude by sha is supported only for secret scan type") - exclusion_type = EXCLUSIONS_BY_SHA_SECTION_NAME + exclusion_type = consts.EXCLUSIONS_BY_SHA_SECTION_NAME exclusion_value = by_sha elif by_path is not None: absolute_path = get_absolute_path(by_path) if not _is_path_to_ignore_exists(absolute_path): - raise click.ClickException("the provided path to ignore by is not exist") - exclusion_type = EXCLUSIONS_BY_PATH_SECTION_NAME + raise click.ClickException('the provided path to ignore by is not exist') + exclusion_type = consts.EXCLUSIONS_BY_PATH_SECTION_NAME exclusion_value = get_absolute_path(absolute_path) elif by_package is not None: - if scan_type != SCA_SCAN_TYPE: - raise click.ClickException("exclude by package is supported only for sca scan type") + if scan_type != consts.SCA_SCAN_TYPE: + raise click.ClickException('exclude by package is supported only for sca scan type') if not _is_package_pattern_valid(by_package): - raise click.ClickException("wrong package pattern. should be name@version.") - exclusion_type = EXCLUSIONS_BY_PACKAGE_SECTION_NAME + raise click.ClickException('wrong package pattern. should be name@version.') + exclusion_type = consts.EXCLUSIONS_BY_PACKAGE_SECTION_NAME exclusion_value = by_package else: - exclusion_type = EXCLUSIONS_BY_RULE_SECTION_NAME + exclusion_type = consts.EXCLUSIONS_BY_RULE_SECTION_NAME exclusion_value = by_rule configuration_scope = 'global' if is_global else 'local' @@ -133,14 +133,14 @@ def _get_client_id_input(current_client_id: str) -> str: f'cycode client id [{_obfuscate_credential(current_client_id)}]', default='', show_default=False ) - return current_client_id if not new_client_id else new_client_id + return new_client_id if new_client_id else current_client_id def _get_client_secret_input(current_client_secret: str) -> str: new_client_secret = click.prompt( f'cycode client secret [{_obfuscate_credential(current_client_secret)}]', default='', show_default=False ) - return current_client_secret if not new_client_secret else new_client_secret + return new_client_secret if new_client_secret else current_client_secret def _get_credentials_update_result_message(): @@ -170,4 +170,4 @@ def _is_path_to_ignore_exists(path: str) -> bool: def _is_package_pattern_valid(package: str) -> bool: - return re.search("^[^@]+@[^@]+$", package) is not None + return re.search('^[^@]+@[^@]+$', package) is not None diff --git a/cycode/cli/utils/path_utils.py b/cycode/cli/utils/path_utils.py index f6bef61b..3522807a 100644 --- a/cycode/cli/utils/path_utils.py +++ b/cycode/cli/utils/path_utils.py @@ -1,7 +1,8 @@ -from typing import Iterable, List, Optional, AnyStr -import pathspec import os from pathlib import Path +from typing import AnyStr, Iterable, List, Optional + +import pathspec from binaryornot.check import is_binary @@ -50,7 +51,7 @@ def get_path_by_os(filename: str) -> str: def _get_all_existing_files_in_directory(path: str): directory = Path(path) - return directory.rglob(r"*") + return directory.rglob(r'*') def is_path_exists(path: str): @@ -68,7 +69,6 @@ def join_paths(path: str, filename: str) -> str: def get_file_content(file_path: str) -> Optional[AnyStr]: try: with open(file_path, 'r', encoding='UTF-8') as f: - content = f.read() - return content + return f.read() except (FileNotFoundError, UnicodeDecodeError): return None diff --git a/cycode/cli/utils/progress_bar.py b/cycode/cli/utils/progress_bar.py index 010405ef..38e00984 100644 --- a/cycode/cli/utils/progress_bar.py +++ b/cycode/cli/utils/progress_bar.py @@ -1,11 +1,11 @@ from abc import ABC, abstractmethod from enum import auto -from typing import NamedTuple, Dict, TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Dict, NamedTuple, Optional import click -from cycode.cyclient.config import get_logger from cycode.cli.utils.enum_utils import AutoCountEnum +from cycode.cyclient.config import get_logger if TYPE_CHECKING: from click._termui_impl import ProgressBar @@ -16,7 +16,6 @@ class ProgressBarSection(AutoCountEnum): PREPARE_LOCAL_FILES = auto() - # UPLOAD_FILES = auto() SCAN = auto() GENERATE_REPORT = auto() @@ -42,7 +41,6 @@ class ProgressBarSectionInfo(NamedTuple): ), # TODO(MarshalX): could be added in the future # ProgressBarSection.UPLOAD_FILES: ProgressBarSectionInfo( - # ProgressBarSection.UPLOAD_FILES, 'Upload files', start_percent=5, stop_percent=10 # ), ProgressBarSection.SCAN: ProgressBarSectionInfo( ProgressBarSection.SCAN, 'Scan in progress', start_percent=5, stop_percent=95 @@ -58,6 +56,7 @@ def _get_section_length(section: 'ProgressBarSection') -> int: class BaseProgressBar(ABC): + @abstractmethod def __init__(self, *args, **kwargs): pass @@ -221,17 +220,17 @@ def get_progress_bar(*, hidden: bool) -> BaseProgressBar: if __name__ == '__main__': # TODO(MarshalX): cover with tests and remove this code - import time import random + import time bar = get_progress_bar(hidden=False) bar.start() for bar_section in ProgressBarSection: - section_capacity = random.randint(500, 1000) + section_capacity = random.randint(500, 1000) # noqa: S311 bar.set_section_length(bar_section, section_capacity) - for i in range(section_capacity): + for _i in range(section_capacity): time.sleep(0.01) bar.update(bar_section) diff --git a/cycode/cli/utils/scan_batch.py b/cycode/cli/utils/scan_batch.py index b5f1c5d2..0f47c30c 100644 --- a/cycode/cli/utils/scan_batch.py +++ b/cycode/cli/utils/scan_batch.py @@ -1,18 +1,18 @@ import os from multiprocessing.pool import ThreadPool -from typing import List, TYPE_CHECKING, Callable, Tuple, Dict +from typing import TYPE_CHECKING, Callable, Dict, List, Tuple from cycode.cli.consts import ( - SCAN_BATCH_MAX_SIZE_IN_BYTES, SCAN_BATCH_MAX_FILES_COUNT, - SCAN_BATCH_SCANS_PER_CPU, SCAN_BATCH_MAX_PARALLEL_SCANS, + SCAN_BATCH_MAX_SIZE_IN_BYTES, + SCAN_BATCH_SCANS_PER_CPU, ) from cycode.cli.models import Document from cycode.cli.utils.progress_bar import ProgressBarSection if TYPE_CHECKING: - from cycode.cli.models import LocalScanResult, CliError + from cycode.cli.models import CliError, LocalScanResult from cycode.cli.utils.progress_bar import BaseProgressBar diff --git a/cycode/cli/utils/shell_executor.py b/cycode/cli/utils/shell_executor.py index 512d7432..6b992738 100644 --- a/cycode/cli/utils/shell_executor.py +++ b/cycode/cli/utils/shell_executor.py @@ -17,7 +17,7 @@ def shell( result = subprocess.run( command, timeout=timeout, - shell=execute_in_shell, + shell=execute_in_shell, # noqa: S603 check=True, capture_output=True, ) @@ -25,9 +25,9 @@ def shell( return result.stdout.decode('UTF-8').strip() except subprocess.CalledProcessError as e: logger.debug(f'Error occurred while running shell command. Exception: {e.stderr}') - except subprocess.TimeoutExpired: - raise click.Abort(f'Command "{command}" timed out') + except subprocess.TimeoutExpired as e: + raise click.Abort(f'Command "{command}" timed out') from e except Exception as e: - raise click.ClickException(f'Unhandled exception: {e}') + raise click.ClickException(f'Unhandled exception: {e}') from e return None diff --git a/cycode/cli/utils/string_utils.py b/cycode/cli/utils/string_utils.py index f301f9ac..790f65b2 100644 --- a/cycode/cli/utils/string_utils.py +++ b/cycode/cli/utils/string_utils.py @@ -1,9 +1,10 @@ import hashlib import math -import re import random +import re import string from sys import getsizeof + from binaryornot.check import is_binary_string from cycode.cli.consts import SCA_SHORTCUT_DEPENDENCY_PATHS @@ -14,12 +15,12 @@ def obfuscate_text(text: str) -> str: start_reveled_len = math.ceil(match_len / 8) end_reveled_len = match_len - (math.ceil(match_len / 8)) - obfuscated = obfuscate_regex.sub("*", text) + obfuscated = obfuscate_regex.sub('*', text) return f'{text[:start_reveled_len]}{obfuscated[start_reveled_len:end_reveled_len]}{text[end_reveled_len:]}' -obfuscate_regex = re.compile(r"[^+\-\s]") +obfuscate_regex = re.compile(r'[^+\-\s]') def is_binary_content(content: str) -> bool: @@ -34,7 +35,7 @@ def get_content_size(content: str): def convert_string_to_bytes(content: str): - return bytes(content, 'utf-8') + return bytes(content, 'UTF-8') def hash_string_to_sha256(content: str): @@ -44,7 +45,7 @@ def hash_string_to_sha256(content: str): def generate_random_string(string_len: int): # letters, digits, and symbols characters = string.ascii_letters + string.digits + string.punctuation - return ''.join(random.choice(characters) for _ in range(string_len)) + return ''.join(random.choice(characters) for _ in range(string_len)) # noqa: S311 def get_position_in_line(text: str, position: int) -> int: diff --git a/cycode/cli/utils/task_timer.py b/cycode/cli/utils/task_timer.py index 7d8efd9e..d6399ba7 100644 --- a/cycode/cli/utils/task_timer.py +++ b/cycode/cli/utils/task_timer.py @@ -1,11 +1,11 @@ -from threading import Thread, Event from _thread import interrupt_main -from typing import Optional, Callable, List, Dict, Type +from threading import Event, Thread from types import TracebackType +from typing import Callable, Dict, List, Optional, Type class FunctionContext: - def __init__(self, function: Callable, args: List = None, kwargs: Dict = None): + def __init__(self, function: Callable, args: Optional[List] = None, kwargs: Optional[Dict] = None): self.function = function self.args = args or [] self.kwargs = kwargs or {} @@ -74,7 +74,7 @@ def __exit__( # catch the exception of interrupt_main before exiting # the with statement and throw timeout error instead if exc_type == KeyboardInterrupt: - raise TimeoutError(f"Task timed out after {self.timeout} seconds") + raise TimeoutError(f'Task timed out after {self.timeout} seconds') def timeout_function(self): interrupt_main() diff --git a/cycode/cli/utils/yaml_utils.py b/cycode/cli/utils/yaml_utils.py index e1732ef8..b9e9f408 100644 --- a/cycode/cli/utils/yaml_utils.py +++ b/cycode/cli/utils/yaml_utils.py @@ -1,20 +1,21 @@ -import yaml from typing import Dict +import yaml + def read_file(filename: str) -> Dict: - with open(filename, 'r', encoding="utf-8") as file: + with open(filename, 'r', encoding='UTF-8') as file: return yaml.safe_load(file) def update_file(filename: str, content: Dict): try: - with open(filename, 'r', encoding="utf-8") as file: + with open(filename, 'r', encoding='UTF-8') as file: file_content = yaml.safe_load(file) except FileNotFoundError: file_content = {} - with open(filename, 'w', encoding="utf-8") as file: + with open(filename, 'w', encoding='UTF-8') as file: file_content = _deep_update(file_content, content) yaml.safe_dump(file_content, file) diff --git a/cycode/cli/zip_file.py b/cycode/cli/zip_file.py index a177b6b7..d4c5c3fc 100644 --- a/cycode/cli/zip_file.py +++ b/cycode/cli/zip_file.py @@ -1,13 +1,13 @@ import os.path -from zipfile import ZipFile, ZIP_DEFLATED from io import BytesIO +from zipfile import ZIP_DEFLATED, ZipFile class InMemoryZip(object): def __init__(self): # Create the in-memory file-like object self.in_memory_zip = BytesIO() - self.zip = ZipFile(self.in_memory_zip, "a", ZIP_DEFLATED, False) + self.zip = ZipFile(self.in_memory_zip, 'a', ZIP_DEFLATED, False) def append(self, filename, unique_id, content): # Write the file to the in-memory zip diff --git a/cycode/cyclient/__init__.py b/cycode/cyclient/__init__.py index f33d4b71..7018a231 100644 --- a/cycode/cyclient/__init__.py +++ b/cycode/cyclient/__init__.py @@ -1,6 +1,5 @@ from .config import logger - __all__ = [ - "logger", + 'logger', ] diff --git a/cycode/cyclient/auth_client.py b/cycode/cyclient/auth_client.py index 379e9bf1..5717c583 100644 --- a/cycode/cyclient/auth_client.py +++ b/cycode/cyclient/auth_client.py @@ -2,9 +2,10 @@ from requests import Response -from .cycode_client import CycodeClient +from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError, NetworkError + from . import models -from cycode.cli.exceptions.custom_exceptions import NetworkError, HttpUnauthorizedError +from .cycode_client import CycodeClient class AuthClient: diff --git a/cycode/cyclient/config.py b/cycode/cyclient/config.py index a73fc9d7..c6ed6432 100644 --- a/cycode/cyclient/config.py +++ b/cycode/cyclient/config.py @@ -3,37 +3,37 @@ import sys from urllib.parse import urlparse -from cycode.cli.consts import * +from cycode.cli import consts from cycode.cli.user_settings.configuration_manager import ConfigurationManager # set io encoding (for windows) from .config_dev import DEV_MODE_ENV_VAR_NAME, DEV_TENANT_ID_ENV_VAR_NAME -sys.stdout.reconfigure(encoding='utf-8') -sys.stderr.reconfigure(encoding='utf-8') +sys.stdout.reconfigure(encoding='UTF-8') +sys.stderr.reconfigure(encoding='UTF-8') # logs logging.basicConfig( stream=sys.stdout, level=logging.DEBUG, - format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", + format='%(asctime)s [%(name)s] %(levelname)s: %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', ) -logging.getLogger("urllib3").setLevel(logging.WARNING) -logging.getLogger("werkzeug").setLevel(logging.WARNING) -logging.getLogger("schedule").setLevel(logging.WARNING) -logging.getLogger("kubernetes").setLevel(logging.WARNING) -logging.getLogger("binaryornot").setLevel(logging.WARNING) -logging.getLogger("chardet").setLevel(logging.WARNING) -logging.getLogger("git.cmd").setLevel(logging.WARNING) -logging.getLogger("git.util").setLevel(logging.WARNING) +logging.getLogger('urllib3').setLevel(logging.WARNING) +logging.getLogger('werkzeug').setLevel(logging.WARNING) +logging.getLogger('schedule').setLevel(logging.WARNING) +logging.getLogger('kubernetes').setLevel(logging.WARNING) +logging.getLogger('binaryornot').setLevel(logging.WARNING) +logging.getLogger('chardet').setLevel(logging.WARNING) +logging.getLogger('git.cmd').setLevel(logging.WARNING) +logging.getLogger('git.util').setLevel(logging.WARNING) # configs DEFAULT_CONFIGURATION = { - TIMEOUT_ENV_VAR_NAME: 300, - LOGGING_LEVEL_ENV_VAR_NAME: logging.INFO, + consts.TIMEOUT_ENV_VAR_NAME: 300, + consts.LOGGING_LEVEL_ENV_VAR_NAME: logging.INFO, DEV_MODE_ENV_VAR_NAME: 'False', - BATCH_SIZE_ENV_VAR_NAME: 20, + consts.BATCH_SIZE_ENV_VAR_NAME: 20, } configuration = dict(DEFAULT_CONFIGURATION, **os.environ) @@ -41,8 +41,8 @@ def get_logger(logger_name=None): logger = logging.getLogger(logger_name) - level = _get_val_as_string(LOGGING_LEVEL_ENV_VAR_NAME) - level = level if level in logging._nameToLevel.keys() else int(level) + level = _get_val_as_string(consts.LOGGING_LEVEL_ENV_VAR_NAME) + level = level if level in logging._nameToLevel else int(level) logger.setLevel(level) return logger @@ -62,7 +62,7 @@ def _get_val_as_int(key): return int(val) if val is not None else None -logger = get_logger("cycode cli") +logger = get_logger('cycode cli') configuration_manager = ConfigurationManager() @@ -71,10 +71,13 @@ def _get_val_as_int(key): urlparse(cycode_api_url) except ValueError as e: logger.warning(f'Invalid cycode api url: {cycode_api_url}, using default value', e) - cycode_api_url = DEFAULT_CYCODE_API_URL + cycode_api_url = consts.DEFAULT_CYCODE_API_URL + +timeout = _get_val_as_int(consts.CYCODE_CLI_REQUEST_TIMEOUT_ENV_VAR_NAME) +if not timeout: + timeout = _get_val_as_int(consts.TIMEOUT_ENV_VAR_NAME) -timeout = _get_val_as_int(CYCODE_CLI_REQUEST_TIMEOUT_ENV_VAR_NAME) or _get_val_as_int(TIMEOUT_ENV_VAR_NAME) dev_mode = _get_val_as_bool(DEV_MODE_ENV_VAR_NAME) dev_tenant_id = _get_val_as_string(DEV_TENANT_ID_ENV_VAR_NAME) -batch_size = _get_val_as_int(BATCH_SIZE_ENV_VAR_NAME) -verbose = _get_val_as_bool(VERBOSE_ENV_VAR_NAME) +batch_size = _get_val_as_int(consts.BATCH_SIZE_ENV_VAR_NAME) +verbose = _get_val_as_bool(consts.VERBOSE_ENV_VAR_NAME) diff --git a/cycode/cyclient/config_dev.py b/cycode/cyclient/config_dev.py index 5913bb16..6345c8f8 100644 --- a/cycode/cyclient/config_dev.py +++ b/cycode/cyclient/config_dev.py @@ -1,3 +1,3 @@ -DEV_CYCODE_API_URL = "http://localhost" -DEV_MODE_ENV_VAR_NAME = "DEV_MODE" -DEV_TENANT_ID_ENV_VAR_NAME = "DEV_TENANT_ID" +DEV_CYCODE_API_URL = 'http://localhost' +DEV_MODE_ENV_VAR_NAME = 'DEV_MODE' +DEV_TENANT_ID_ENV_VAR_NAME = 'DEV_TENANT_ID' diff --git a/cycode/cyclient/cycode_client_base.py b/cycode/cyclient/cycode_client_base.py index e6220ac2..d4c27c0e 100644 --- a/cycode/cyclient/cycode_client_base.py +++ b/cycode/cyclient/cycode_client_base.py @@ -1,13 +1,14 @@ import platform -from typing import Dict +from typing import ClassVar, Dict, Optional -from cycode.cyclient import logger -from requests import Response, request, exceptions +from requests import Response, exceptions, request from cycode import __version__ +from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError, NetworkError +from cycode.cli.user_settings.configuration_manager import ConfigurationManager +from cycode.cyclient import logger + from . import config -from ..cli.exceptions.custom_exceptions import NetworkError, HttpUnauthorizedError -from ..cli.user_settings.configuration_manager import ConfigurationManager def get_cli_user_agent() -> str: @@ -28,7 +29,7 @@ def get_cli_user_agent() -> str: class CycodeClientBase: - MANDATORY_HEADERS: Dict[str, str] = {'User-Agent': get_cli_user_agent()} + MANDATORY_HEADERS: ClassVar[Dict[str, str]] = {'User-Agent': get_cli_user_agent()} def __init__(self, api_url: str): self.timeout = config.timeout @@ -42,17 +43,17 @@ def reset_user_agent() -> None: def enrich_user_agent(user_agent_suffix: str) -> None: CycodeClientBase.MANDATORY_HEADERS['User-Agent'] += f' {user_agent_suffix}' - def post(self, url_path: str, body: dict = None, headers: dict = None, **kwargs) -> Response: + def post(self, url_path: str, body: Optional[dict] = None, headers: Optional[dict] = None, **kwargs) -> Response: return self._execute(method='post', endpoint=url_path, json=body, headers=headers, **kwargs) - def put(self, url_path: str, body: dict = None, headers: dict = None, **kwargs) -> Response: + def put(self, url_path: str, body: Optional[dict] = None, headers: Optional[dict] = None, **kwargs) -> Response: return self._execute(method='put', endpoint=url_path, json=body, headers=headers, **kwargs) - def get(self, url_path: str, headers: dict = None, **kwargs) -> Response: + def get(self, url_path: str, headers: Optional[dict] = None, **kwargs) -> Response: return self._execute(method='get', endpoint=url_path, headers=headers, **kwargs) def _execute( - self, method: str, endpoint: str, headers: dict = None, without_auth: bool = False, **kwargs + self, method: str, endpoint: str, headers: Optional[dict] = None, without_auth: bool = False, **kwargs ) -> Response: url = self.build_full_url(self.api_url, endpoint) logger.debug(f'Executing {method.upper()} request to {url}') @@ -68,7 +69,7 @@ def _execute( except Exception as e: self._handle_exception(e) - def get_request_headers(self, additional_headers: dict = None, **kwargs) -> dict: + def get_request_headers(self, additional_headers: Optional[dict] = None, **kwargs) -> dict: if additional_headers is None: return self.MANDATORY_HEADERS.copy() return {**self.MANDATORY_HEADERS, **additional_headers} @@ -79,7 +80,8 @@ def build_full_url(self, url: str, endpoint: str) -> str: def _handle_exception(self, e: Exception): if isinstance(e, exceptions.Timeout): raise NetworkError(504, 'Timeout Error', e.response) - elif isinstance(e, exceptions.HTTPError): + + if isinstance(e, exceptions.HTTPError): self._handle_http_exception(e) elif isinstance(e, exceptions.ConnectionError): raise NetworkError(502, 'Connection Error', e.response) diff --git a/cycode/cyclient/cycode_dev_based_client.py b/cycode/cyclient/cycode_dev_based_client.py index 31f182bf..bb647acf 100644 --- a/cycode/cyclient/cycode_dev_based_client.py +++ b/cycode/cyclient/cycode_dev_based_client.py @@ -1,3 +1,5 @@ +from typing import Optional + from .config import dev_tenant_id from .cycode_client_base import CycodeClientBase @@ -10,11 +12,11 @@ class CycodeDevBasedClient(CycodeClientBase): def __init__(self, api_url): super().__init__(api_url) - def get_request_headers(self, additional_headers: dict = None): + def get_request_headers(self, additional_headers: Optional[dict] = None): headers = super().get_request_headers(additional_headers=additional_headers) headers['X-Tenant-Id'] = dev_tenant_id return headers def build_full_url(self, url, endpoint): - return f"{url}:{endpoint}" + return f'{url}:{endpoint}' diff --git a/cycode/cyclient/cycode_token_based_client.py b/cycode/cyclient/cycode_token_based_client.py index f1043c57..b8898a41 100644 --- a/cycode/cyclient/cycode_token_based_client.py +++ b/cycode/cyclient/cycode_token_based_client.py @@ -1,4 +1,5 @@ from threading import Lock +from typing import Optional import arrow @@ -31,7 +32,7 @@ def refresh_api_token_if_needed(self) -> None: def refresh_api_token(self) -> None: auth_response = self.post( - url_path=f'api/v1/auth/api-token', + url_path='api/v1/auth/api-token', body={'clientId': self.client_id, 'secret': self.client_secret}, without_auth=True, ) @@ -40,7 +41,7 @@ def refresh_api_token(self) -> None: self._api_token = auth_response_data['token'] self._expires_in = arrow.utcnow().shift(seconds=auth_response_data['expires_in'] * 0.8) - def get_request_headers(self, additional_headers: dict = None, without_auth=False) -> dict: + def get_request_headers(self, additional_headers: Optional[dict] = None, without_auth=False) -> dict: headers = super().get_request_headers(additional_headers=additional_headers) if not without_auth: diff --git a/cycode/cyclient/models.py b/cycode/cyclient/models.py index 330fd2d9..89e39b11 100644 --- a/cycode/cyclient/models.py +++ b/cycode/cyclient/models.py @@ -1,6 +1,7 @@ from dataclasses import dataclass -from typing import List, Dict, Optional -from marshmallow import Schema, fields, EXCLUDE, post_load +from typing import Dict, List, Optional + +from marshmallow import EXCLUDE, Schema, fields, post_load class Detection(Schema): @@ -26,7 +27,7 @@ def __repr__(self) -> str: f'type:{self.type}, ' f'severity:{self.severity}, ' f'message:{self.message}, ' - f'detection_details:{repr(self.detection_details)}, ' + f'detection_details:{self.detection_details!r}, ' f'detection_rule_id:{self.detection_rule_id}' ) @@ -75,8 +76,8 @@ def __init__( did_detect: bool, detections_per_file: List[DetectionsPerFile], report_url: Optional[str] = None, - scan_id: str = None, - err: str = None, + scan_id: Optional[str] = None, + err: Optional[str] = None, ): super().__init__() self.did_detect = did_detect @@ -102,7 +103,13 @@ def build_dto(self, data, **kwargs): class ScanResult(Schema): - def __init__(self, did_detect: bool, scan_id: str = None, detections: List[Detection] = None, err: str = None): + def __init__( + self, + did_detect: bool, + scan_id: Optional[str] = None, + detections: Optional[List[Detection]] = None, + err: Optional[str] = None, + ): super().__init__() self.did_detect = did_detect self.scan_id = scan_id @@ -125,7 +132,7 @@ def build_dto(self, data, **kwargs): class ScanInitializationResponse(Schema): - def __init__(self, scan_id: str = None, err: str = None): + def __init__(self, scan_id: Optional[str] = None, err: Optional[str] = None): super().__init__() self.scan_id = scan_id self.err = err @@ -146,13 +153,13 @@ def build_dto(self, data, **kwargs): class ScanDetailsResponse(Schema): def __init__( self, - id: str = None, - scan_status: str = None, - results_count: int = None, - metadata: str = None, - message: str = None, - scan_update_at: str = None, - err: str = None, + id: Optional[str] = None, + scan_status: Optional[str] = None, + results_count: Optional[int] = None, + metadata: Optional[str] = None, + message: Optional[str] = None, + scan_update_at: Optional[str] = None, + err: Optional[str] = None, ): super().__init__() self.id = id @@ -246,7 +253,7 @@ def __init__(self, name: str, kind: str): self.kind = kind def __str__(self): - return "Name: {0}, Kind: {1}".format(self.name, self.kind) + return 'Name: {0}, Kind: {1}'.format(self.name, self.kind) class AuthenticationSession(Schema): diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index e6a46a3b..03844da1 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -1,9 +1,10 @@ import json -from typing import List +from typing import List, Optional from requests import Response from cycode.cli.zip_file import InMemoryZip + from . import models from .cycode_client_base import CycodeClientBase from .scan_config.scan_config_base import ScanConfigBase @@ -24,7 +25,7 @@ def content_scan(self, scan_type: str, file_name: str, content: str, is_git_diff def file_scan(self, scan_type: str, path: str) -> models.ScanResult: url_path = f'{self.scan_config.get_service_name(scan_type)}/{self.SCAN_CONTROLLER_PATH}' - files = {'file': open(path, 'rb')} + files = {'file': open(path, 'rb')} # noqa: SIM115 requests lib should care about closing response = self.scan_cycode_client.post(url_path=url_path, files=files) return self.parse_scan_response(response) @@ -104,8 +105,8 @@ def get_scan_detections(self, scan_id: str) -> List[dict]: return detections def get_scan_detections_count(self, scan_id: str) -> int: - url_path = f'{self.scan_config.get_detections_prefix()}/{self.DETECTIONS_SERVICE_CONTROLLER_PATH}/count?scan_id={scan_id}' - response = self.scan_cycode_client.get(url_path=url_path) + url_path = f'{self.scan_config.get_detections_prefix()}/{self.DETECTIONS_SERVICE_CONTROLLER_PATH}/count' + response = self.scan_cycode_client.get(url_path=url_path, params={'scan_id': scan_id}) return response.json().get('count', 0) def commit_range_zipped_file_scan( @@ -131,10 +132,12 @@ def parse_zipped_file_scan_response(response: Response) -> models.ZippedFileScan return models.ZippedFileScanResultSchema().load(response.json()) @staticmethod - def get_service_name(scan_type: str) -> str: + def get_service_name(scan_type: str) -> Optional[str]: if scan_type == 'secret': return 'secret' - elif scan_type == 'iac': + if scan_type == 'iac': return 'iac' - elif scan_type == 'sca' or scan_type == 'sast': + if scan_type == 'sca' or scan_type == 'sast': return 'scans' + + return None diff --git a/cycode/cyclient/scan_config/scan_config_base.py b/cycode/cyclient/scan_config/scan_config_base.py index 81fec2a6..b4e0cb53 100644 --- a/cycode/cyclient/scan_config/scan_config_base.py +++ b/cycode/cyclient/scan_config/scan_config_base.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from typing import Optional class ScanConfigBase(ABC): @@ -19,10 +20,11 @@ class DevScanConfig(ScanConfigBase): def get_service_name(self, scan_type): if scan_type == 'secret': return '5025' - elif scan_type == 'iac': + if scan_type == 'iac': return '5026' - elif scan_type == 'sca' or scan_type == 'sast': + if scan_type == 'sca' or scan_type == 'sast': return '5004' + return None def get_scans_prefix(self): return '5004' @@ -32,14 +34,16 @@ def get_detections_prefix(self): class DefaultScanConfig(ScanConfigBase): - def get_service_name(self, scan_type): + def get_service_name(self, scan_type) -> Optional[str]: if scan_type == 'secret': return 'secret' - elif scan_type == 'iac': + if scan_type == 'iac': return 'iac' - elif scan_type == 'sca' or scan_type == 'sast': + if scan_type == 'sca' or scan_type == 'sast': return 'scans' + return None + def get_scans_prefix(self): return 'scans' diff --git a/cycode/cyclient/scan_config/scan_config_creator.py b/cycode/cyclient/scan_config/scan_config_creator.py index adcaf1d9..c138565f 100644 --- a/cycode/cyclient/scan_config/scan_config_creator.py +++ b/cycode/cyclient/scan_config/scan_config_creator.py @@ -1,11 +1,11 @@ from typing import Tuple -from ..config import dev_mode -from ..config_dev import DEV_CYCODE_API_URL -from ..cycode_dev_based_client import CycodeDevBasedClient -from ..cycode_token_based_client import CycodeTokenBasedClient -from ..scan_client import ScanClient -from ..scan_config.scan_config_base import DefaultScanConfig, DevScanConfig +from cycode.cyclient.config import dev_mode +from cycode.cyclient.config_dev import DEV_CYCODE_API_URL +from cycode.cyclient.cycode_dev_based_client import CycodeDevBasedClient +from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient +from cycode.cyclient.scan_client import ScanClient +from cycode.cyclient.scan_config.scan_config_base import DefaultScanConfig, DevScanConfig def create_scan_client(client_id: str, client_secret: str) -> ScanClient: diff --git a/poetry.lock b/poetry.lock index 93240772..b5584637 100644 --- a/poetry.lock +++ b/poetry.lock @@ -712,6 +712,32 @@ urllib3 = ">=1.25.10" [package.extras] tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli", "tomli-w", "types-requests"] +[[package]] +name = "ruff" +version = "0.0.277" +description = "An extremely fast Python linter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.0.277-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:3250b24333ef419b7a232080d9724ccc4d2da1dbbe4ce85c4caa2290d83200f8"}, + {file = "ruff-0.0.277-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:3e60605e07482183ba1c1b7237eca827bd6cbd3535fe8a4ede28cbe2a323cb97"}, + {file = "ruff-0.0.277-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7baa97c3d7186e5ed4d5d4f6834d759a27e56cf7d5874b98c507335f0ad5aadb"}, + {file = "ruff-0.0.277-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:74e4b206cb24f2e98a615f87dbe0bde18105217cbcc8eb785bb05a644855ba50"}, + {file = "ruff-0.0.277-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:479864a3ccd8a6a20a37a6e7577bdc2406868ee80b1e65605478ad3b8eb2ba0b"}, + {file = "ruff-0.0.277-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:468bfb0a7567443cec3d03cf408d6f562b52f30c3c29df19927f1e0e13a40cd7"}, + {file = "ruff-0.0.277-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f32ec416c24542ca2f9cc8c8b65b84560530d338aaf247a4a78e74b99cd476b4"}, + {file = "ruff-0.0.277-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:14a7b2f00f149c5a295f188a643ac25226ff8a4d08f7a62b1d4b0a1dc9f9b85c"}, + {file = "ruff-0.0.277-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9879f59f763cc5628aa01c31ad256a0f4dc61a29355c7315b83c2a5aac932b5"}, + {file = "ruff-0.0.277-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f612e0a14b3d145d90eb6ead990064e22f6f27281d847237560b4e10bf2251f3"}, + {file = "ruff-0.0.277-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:323b674c98078be9aaded5b8b51c0d9c424486566fb6ec18439b496ce79e5998"}, + {file = "ruff-0.0.277-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3a43fbe026ca1a2a8c45aa0d600a0116bec4dfa6f8bf0c3b871ecda51ef2b5dd"}, + {file = "ruff-0.0.277-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:734165ea8feb81b0d53e3bf523adc2413fdb76f1264cde99555161dd5a725522"}, + {file = "ruff-0.0.277-py3-none-win32.whl", hash = "sha256:88d0f2afb2e0c26ac1120e7061ddda2a566196ec4007bd66d558f13b374b9efc"}, + {file = "ruff-0.0.277-py3-none-win_amd64.whl", hash = "sha256:6fe81732f788894a00f6ade1fe69e996cc9e485b7c35b0f53fb00284397284b2"}, + {file = "ruff-0.0.277-py3-none-win_arm64.whl", hash = "sha256:2d4444c60f2e705c14cd802b55cd2b561d25bf4311702c463a002392d3116b22"}, + {file = "ruff-0.0.277.tar.gz", hash = "sha256:2dab13cdedbf3af6d4427c07f47143746b6b95d9e4a254ac369a0edb9280a0d2"}, +] + [[package]] name = "setuptools" version = "67.7.2" @@ -879,4 +905,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = ">=3.7,<3.12" -content-hash = "833e8a506d7c9c0ef2585dc822d37d9b665d51cd0ef4c49b3d6c3397bdd8ecea" +content-hash = "5cb535c853d7233ac226c8ea2e50ca05ee7243bfb81ce72f76e93be3f56d1e7f" diff --git a/pyproject.toml b/pyproject.toml index 69232760..fa51f398 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ dunamai = ">=1.16.1,<1.17.0" [tool.poetry.group.dev.dependencies] black = ">=23.3.0,<23.4.0" +ruff = "0.0.277" [tool.pytest.ini_options] log_cli = true @@ -84,6 +85,48 @@ exclude = ''' ) ''' +[tool.ruff] +extend-select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # Pyflakes + "I", # isort + "C90", # flake8-comprehensions + "B", # flake8-bugbear + "Q", # flake8-quotes + "S", # flake8-bandit + "ASYNC", # flake8-async + # "ANN", # flake8-annotations. Enable later + "C", + "BLE", + "ERA", + "ICN", + "INP", + "ISC", + "NPY", + "PGH", + "PIE", + "RET", + "RSE", + "RUF", + "SIM", + "T20", + # "TCH", # it can't be autofixed yet + "TID", + "YTT", +] +line-length = 120 +target-version = "py37" + +[tool.ruff.flake8-quotes] +docstring-quotes = "double" +multiline-quotes = "double" +inline-quotes = "single" + +[tool.ruff.per-file-ignores] +"tests/*.py" = ["S101", "S105"] +"cycode/*.py" = ["BLE001"] + [build-system] requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning"] build-backend = "poetry_dynamic_versioning.backend" diff --git a/tests/__init__.py b/tests/__init__.py index 421a461f..80fbb2d8 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -16,12 +16,12 @@ POD_MOCK = { 'metadata': { - "name": "pod-template-123xyz", - "namespace": "default", + 'name': 'pod-template-123xyz', + 'namespace': 'default', 'ownerReferences': [ { - "kind": "Deployment", - "name": "nginx-deployment", + 'kind': 'Deployment', + 'name': 'nginx-deployment', } ], } @@ -31,4 +31,4 @@ def list_to_str(values): - return ",".join([str(val) for val in values]) + return ','.join([str(val) for val in values]) diff --git a/tests/cli/test_code_scanner.py b/tests/cli/test_code_scanner.py index c4c23e2a..041dac49 100644 --- a/tests/cli/test_code_scanner.py +++ b/tests/cli/test_code_scanner.py @@ -6,7 +6,7 @@ from git import InvalidGitRepositoryError from requests import Response -from cycode.cli.code_scanner import _handle_exception, _is_file_relevant_for_sca_scan, exclude_irrelevant_files # noqa +from cycode.cli.code_scanner import _handle_exception, _is_file_relevant_for_sca_scan from cycode.cli.exceptions import custom_exceptions @@ -36,21 +36,19 @@ def test_handle_exception_soft_fail( def test_handle_exception_unhandled_error(ctx: click.Context): - with ctx: - with pytest.raises(ClickException): - _handle_exception(ctx, ValueError('test')) + with ctx, pytest.raises(ClickException): + _handle_exception(ctx, ValueError('test')) - assert ctx.obj.get('did_fail') is True - assert ctx.obj.get('soft_fail') is None + assert ctx.obj.get('did_fail') is True + assert ctx.obj.get('soft_fail') is None def test_handle_exception_click_error(ctx: click.Context): - with ctx: - with pytest.raises(ClickException): - _handle_exception(ctx, click.ClickException('test')) + with ctx, pytest.raises(ClickException): + _handle_exception(ctx, click.ClickException('test')) - assert ctx.obj.get('did_fail') is True - assert ctx.obj.get('soft_fail') is None + assert ctx.obj.get('did_fail') is True + assert ctx.obj.get('soft_fail') is None def test_handle_exception_verbose(monkeypatch): @@ -61,9 +59,8 @@ def mock_secho(msg, *_, **__): monkeypatch.setattr(click, 'secho', mock_secho) - with ctx: - with pytest.raises(ClickException): - _handle_exception(ctx, ValueError('test')) + with ctx, pytest.raises(ClickException): + _handle_exception(ctx, ValueError('test')) def test_is_file_relevant_for_sca_scan(): diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py index 92ec2e58..2996c0d4 100644 --- a/tests/cli/test_main.py +++ b/tests/cli/test_main.py @@ -1,13 +1,12 @@ import json - -import pytest from typing import TYPE_CHECKING +import pytest import responses from click.testing import CliRunner from cycode.cli.main import main_cli -from tests.conftest import TEST_FILES_PATH, CLI_ENV_VARS +from tests.conftest import CLI_ENV_VARS, TEST_FILES_PATH from tests.cyclient.test_scan_client import get_zipped_file_scan_response, get_zipped_file_scan_url _PATH_TO_SCAN = TEST_FILES_PATH.joinpath('zip_content').absolute() diff --git a/tests/cyclient/test_auth_client.py b/tests/cyclient/test_auth_client.py index d6647b27..b3898fd3 100644 --- a/tests/cyclient/test_auth_client.py +++ b/tests/cyclient/test_auth_client.py @@ -3,13 +3,13 @@ import responses from requests import Timeout +from cycode.cli.exceptions.custom_exceptions import CycodeError from cycode.cyclient.auth_client import AuthClient from cycode.cyclient.models import ( - AuthenticationSession, ApiTokenGenerationPollingResponse, ApiTokenGenerationPollingResponseSchema, + AuthenticationSession, ) -from cycode.cli.exceptions.custom_exceptions import CycodeError @pytest.fixture(scope='module') diff --git a/tests/cyclient/test_client_base.py b/tests/cyclient/test_client_base.py index dff25c52..5a7810a8 100644 --- a/tests/cyclient/test_client_base.py +++ b/tests/cyclient/test_client_base.py @@ -9,7 +9,7 @@ def test_mandatory_headers(): client = CycodeClientBase(config.cycode_api_url) - assert client.MANDATORY_HEADERS == expected_headers + assert expected_headers == client.MANDATORY_HEADERS def test_get_request_headers(): diff --git a/tests/cyclient/test_scan_client.py b/tests/cyclient/test_scan_client.py index 41c15a71..fecbaed2 100644 --- a/tests/cyclient/test_scan_client.py +++ b/tests/cyclient/test_scan_client.py @@ -1,23 +1,21 @@ import os -from uuid import uuid4, UUID +from typing import List, Optional +from uuid import UUID, uuid4 import pytest import requests import responses -from typing import List - from requests import Timeout from requests.exceptions import ProxyError +from cycode.cli.code_scanner import zip_documents_to_scan from cycode.cli.config import config -from cycode.cli.zip_file import InMemoryZip +from cycode.cli.exceptions.custom_exceptions import CycodeError, HttpUnauthorizedError from cycode.cli.models import Document -from cycode.cli.code_scanner import zip_documents_to_scan -from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError, CycodeError +from cycode.cli.zip_file import InMemoryZip from cycode.cyclient.scan_client import ScanClient from tests.conftest import TEST_FILES_PATH - _ZIP_CONTENT_PATH = TEST_FILES_PATH.joinpath('zip_content').absolute() @@ -31,7 +29,8 @@ def zip_scan_resources(scan_type: str, scan_client: ScanClient): def get_zipped_file_scan_url(scan_type: str, scan_client: ScanClient) -> str: api_url = scan_client.scan_cycode_client.api_url # TODO(MarshalX): create method in the scan client to build this url - return f'{api_url}/{scan_client.scan_config.get_service_name(scan_type)}/{scan_client.SCAN_CONTROLLER_PATH}/zipped-file' + service_url = f'{api_url}/{scan_client.scan_config.get_service_name(scan_type)}' + return f'{service_url}/{scan_client.SCAN_CONTROLLER_PATH}/zipped-file' def get_test_zip_file(scan_type: str) -> InMemoryZip: @@ -46,7 +45,7 @@ def get_test_zip_file(scan_type: str) -> InMemoryZip: return zip_documents_to_scan(scan_type, InMemoryZip(), test_documents) -def get_zipped_file_scan_response(url: str, scan_id: UUID = None) -> responses.Response: +def get_zipped_file_scan_response(url: str, scan_id: Optional[UUID] = None) -> responses.Response: if not scan_id: scan_id = uuid4() diff --git a/tests/cyclient/test_token_based_client.py b/tests/cyclient/test_token_based_client.py index b906a976..ebc672fb 100644 --- a/tests/cyclient/test_token_based_client.py +++ b/tests/cyclient/test_token_based_client.py @@ -2,7 +2,7 @@ import responses from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient -from ..conftest import _EXPECTED_API_TOKEN +from tests.conftest import _EXPECTED_API_TOKEN @responses.activate @@ -19,7 +19,7 @@ def test_api_token_expired(token_based_client: CycodeTokenBasedClient, api_token responses.add(api_token_response) # this property performs HTTP req to refresh the token. IDE doesn't know it - token_based_client.api_token + token_based_client.api_token # noqa: B018 # mark token as expired token_based_client._expires_in = arrow.utcnow().shift(hours=-1) diff --git a/tests/test_files/zip_content/__init__.py b/tests/test_files/zip_content/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_files/zip_content/sast.py b/tests/test_files/zip_content/sast.py index aa6df96e..2de8a5ca 100644 --- a/tests/test_files/zip_content/sast.py +++ b/tests/test_files/zip_content/sast.py @@ -1,4 +1,3 @@ import requests - -requests.get('https://slack.com/api/conversations.list', verify=False) +requests.get('https://slack.com/api/conversations.list', verify=False) # noqa: S113, S501 diff --git a/tests/test_files/zip_content/secrets.py b/tests/test_files/zip_content/secrets.py index a8c7d82a..627ee470 100644 --- a/tests/test_files/zip_content/secrets.py +++ b/tests/test_files/zip_content/secrets.py @@ -1 +1 @@ -slack_bot_token = "xoxb-1234518014707-1234518014707-M14123412341234Fra15WS" +slack_bot_token = 'xoxb-1234518014707-1234518014707-M14123412341234Fra15WS' diff --git a/tests/test_models.py b/tests/test_models.py index 270b9780..bb583b8e 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,14 +1,14 @@ -from cycode.cyclient.models import ResourcesCollection, InternalMetadata, K8SResource +from cycode.cyclient.models import InternalMetadata, K8SResource, ResourcesCollection from tests import PODS_MOCK def test_batch_resources_to_json(): batch = ResourcesCollection('pod', 'default', PODS_MOCK, 77777) json_dict = batch.to_json() - assert 'resources' in json_dict.keys() - assert 'namespace' in json_dict.keys() - assert 'total_count' in json_dict.keys() - assert 'type' in json_dict.keys() + assert 'resources' in json_dict + assert 'namespace' in json_dict + assert 'total_count' in json_dict + assert 'type' in json_dict assert json_dict['total_count'] == 77777 assert json_dict['type'] == 'pod' assert json_dict['namespace'] == 'default' diff --git a/tests/user_settings/test_configuration_manager.py b/tests/user_settings/test_configuration_manager.py index 8f4e01f1..1da4a346 100644 --- a/tests/user_settings/test_configuration_manager.py +++ b/tests/user_settings/test_configuration_manager.py @@ -1,7 +1,7 @@ from mock import Mock -from cycode.cli.user_settings.configuration_manager import ConfigurationManager from cycode.cli.consts import DEFAULT_CYCODE_API_URL +from cycode.cli.user_settings.configuration_manager import ConfigurationManager """ we check for base url in the three places, in the following order: diff --git a/tests/user_settings/test_user_settings_commands.py b/tests/user_settings/test_user_settings_commands.py index cd5e5974..b63c1e5c 100644 --- a/tests/user_settings/test_user_settings_commands.py +++ b/tests/user_settings/test_user_settings_commands.py @@ -5,8 +5,8 @@ def test_set_credentials_no_exist_credentials_in_file(mocker): # Arrange - client_id_user_input = "new client id" - client_secret_user_input = "new client secret" + client_id_user_input = 'new client id' + client_secret_user_input = 'new client secret' mocker.patch( 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', return_value=(None, None), @@ -28,8 +28,8 @@ def test_set_credentials_no_exist_credentials_in_file(mocker): def test_set_credentials_update_current_credentials_in_file(mocker): # Arrange - client_id_user_input = "new client id" - client_secret_user_input = "new client secret" + client_id_user_input = 'new client id' + client_secret_user_input = 'new client secret' mocker.patch( 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', return_value=('client id file', 'client secret file'), @@ -51,7 +51,7 @@ def test_set_credentials_update_current_credentials_in_file(mocker): def test_set_credentials_update_only_client_id(mocker): # Arrange - client_id_user_input = "new client id" + client_id_user_input = 'new client id' current_client_id = 'client secret file' mocker.patch( 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', @@ -74,7 +74,7 @@ def test_set_credentials_update_only_client_id(mocker): def test_set_credentials_update_only_client_secret(mocker): # Arrange - client_secret_user_input = "new client secret" + client_secret_user_input = 'new client secret' current_client_id = 'client secret file' mocker.patch( 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', @@ -97,8 +97,8 @@ def test_set_credentials_update_only_client_secret(mocker): def test_set_credentials_should_not_update_file(mocker): # Arrange - client_id_user_input = "" - client_secret_user_input = "" + client_id_user_input = '' + client_secret_user_input = '' mocker.patch( 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', return_value=('client id file', 'client secret file'), diff --git a/tests/utils/test_string_utils.py b/tests/utils/test_string_utils.py index 7b20fd4c..2a78b9f6 100644 --- a/tests/utils/test_string_utils.py +++ b/tests/utils/test_string_utils.py @@ -2,6 +2,6 @@ def test_shortcut_dependency_paths_list_single_dependencies(): - dependency_paths = "A, A -> B, A -> B -> C" - expected_result = "A\n\nA -> B\n\nA -> ... -> C" + dependency_paths = 'A, A -> B, A -> B -> C' + expected_result = 'A\n\nA -> B\n\nA -> ... -> C' assert shortcut_dependency_paths(dependency_paths) == expected_result From 2368b5a0357eb306a00be27e264d79b9a8941a3b Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Thu, 6 Jul 2023 10:21:04 +0200 Subject: [PATCH 007/257] CM-24619 - CLI pre-commit hook can't be installed properly (#135) --- .gitignore | 1 + .pre-commit-hooks.yaml | 6 ++++-- cycode/__init__.py | 14 ++++++++++++++ cycode/pre-commit-hook-version | 1 + pyproject.toml | 2 +- 5 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 cycode/pre-commit-hook-version diff --git a/.gitignore b/.gitignore index 1de92d03..9b17b29b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.DS_Store .idea *.iml .env diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index d3b55ce6..44b0dd1c 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -1,10 +1,12 @@ - id: cycode name: Cycode pre commit defender language: python + language_version: python3 entry: cycode - args: [ 'scan', 'pre_commit' ] + args: [ '--no-progress-meter', 'scan', 'pre_commit' ] - id: cycode-sca name: Cycode SCA pre commit defender language: python + language_version: python3 entry: cycode - args: [ 'scan', '--scan-type', 'sca', 'pre_commit' ] \ No newline at end of file + args: [ '--no-progress-meter', 'scan', '--scan-type', 'sca', 'pre_commit' ] diff --git a/cycode/__init__.py b/cycode/__init__.py index 4ce71ef1..2b478887 100644 --- a/cycode/__init__.py +++ b/cycode/__init__.py @@ -1 +1,15 @@ __version__ = '0.0.0' # DON'T TOUCH. Placeholder. Will be filled automatically on poetry build from Git Tag + +if __version__ == '0.0.1.dev1': + # If CLI was installed from shallow clone, __version__ will be 0.0.1.dev1 due to non-strict versioning. + # This happens when installing CLI as pre-commit hook. + # We are not able to provide the version based on Git Tag in this case. + # This fallback version is maintained manually. + + # One benefit of it is that we could pass the version with a special suffix to mark pre-commit hook usage. + + import os + + version_filepath = os.path.join(os.path.dirname(__file__), 'pre-commit-hook-version') + with open(version_filepath, 'r', encoding='UTF-8') as f: + __version__ = f.read().strip() diff --git a/cycode/pre-commit-hook-version b/cycode/pre-commit-hook-version new file mode 100644 index 00000000..d1e8df28 --- /dev/null +++ b/cycode/pre-commit-hook-version @@ -0,0 +1 @@ +0.2.5-pre-commit \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index fa51f398..dd3c8e04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,7 @@ log_cli = true [tool.poetry-dynamic-versioning] # poetry self add "poetry-dynamic-versioning[plugin]" enable = true -strict = true +strict = false bump = true metadata = false vcs = "git" From 8cb7da4138f2c7d62f0775c7e8b8cd93965a8fb1 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Thu, 6 Jul 2023 14:06:32 +0200 Subject: [PATCH 008/257] CM-25040 - Improve type annotations (#137) --- cycode/cli/auth/auth_command.py | 18 +++-- cycode/cli/auth/auth_manager.py | 29 ++++---- cycode/cli/ci_integrations.py | 8 +-- cycode/cli/code_scanner.py | 70 +++++++++++-------- cycode/cli/exceptions/custom_exceptions.py | 20 +++--- .../maven/base_restore_maven_dependencies.py | 6 +- .../maven/restore_gradle_dependencies.py | 2 +- .../maven/restore_maven_dependencies.py | 10 +-- cycode/cli/helpers/sca_code_scanner.py | 16 +++-- cycode/cli/main.py | 34 ++++----- cycode/cli/models.py | 6 +- cycode/cli/printers/base_printer.py | 2 +- cycode/cli/printers/base_table_printer.py | 4 +- cycode/cli/printers/console_printer.py | 2 +- cycode/cli/printers/sca_table_printer.py | 4 +- cycode/cli/printers/table.py | 2 +- cycode/cli/printers/text_printer.py | 25 +++---- cycode/cli/user_settings/base_file_manager.py | 9 +-- .../cli/user_settings/config_file_manager.py | 35 +++++----- .../user_settings/configuration_manager.py | 12 ++-- .../cli/user_settings/credentials_manager.py | 10 +-- .../user_settings/user_settings_commands.py | 8 +-- cycode/cli/utils/enum_utils.py | 3 +- cycode/cli/utils/path_utils.py | 6 +- cycode/cli/utils/progress_bar.py | 9 +-- cycode/cli/utils/scan_batch.py | 3 +- cycode/cli/utils/shell_executor.py | 2 +- cycode/cli/utils/string_utils.py | 8 +-- cycode/cli/utils/task_timer.py | 16 ++--- cycode/cli/utils/yaml_utils.py | 8 +-- cycode/cli/zip_file.py | 9 +-- cycode/cyclient/auth_client.py | 2 +- cycode/cyclient/config.py | 14 ++-- cycode/cyclient/cycode_client.py | 2 +- cycode/cyclient/cycode_client_base.py | 8 +-- cycode/cyclient/cycode_dev_based_client.py | 8 +-- cycode/cyclient/cycode_token_based_client.py | 4 +- cycode/cyclient/models.py | 56 +++++++-------- cycode/cyclient/scan_client.py | 4 +- .../cyclient/scan_config/scan_config_base.py | 36 +++++----- cycode/cyclient/utils.py | 10 --- pyproject.toml | 10 ++- tests/__init__.py | 4 -- tests/cli/test_code_scanner.py | 18 +++-- tests/cli/test_main.py | 2 +- tests/conftest.py | 2 +- .../scan_config/test_default_scan_config.py | 6 +- .../scan_config/test_dev_scan_config.py | 6 +- tests/cyclient/test_auth_client.py | 16 ++--- tests/cyclient/test_client.py | 2 +- tests/cyclient/test_client_base.py | 8 +-- tests/cyclient/test_dev_based_client.py | 4 +- tests/cyclient/test_scan_client.py | 24 ++++--- tests/cyclient/test_token_based_client.py | 6 +- tests/test_code_scanner.py | 2 +- tests/test_models.py | 4 +- tests/test_zip_file.py | 4 +- .../test_configuration_manager.py | 20 ++++-- .../test_user_settings_commands.py | 17 +++-- tests/utils/test_path_utils.py | 10 +-- tests/utils/test_string_utils.py | 2 +- 61 files changed, 381 insertions(+), 326 deletions(-) delete mode 100644 cycode/cyclient/utils.py diff --git a/cycode/cli/auth/auth_command.py b/cycode/cli/auth/auth_command.py index 63384276..3fe037bf 100644 --- a/cycode/cli/auth/auth_command.py +++ b/cycode/cli/auth/auth_command.py @@ -15,7 +15,7 @@ invoke_without_command=True, short_help='Authenticates your machine to associate CLI with your cycode account' ) @click.pass_context -def authenticate(context: click.Context): +def authenticate(context: click.Context) -> None: if context.invoked_subcommand is not None: # if it is a subcommand, do nothing return @@ -34,7 +34,7 @@ def authenticate(context: click.Context): @authenticate.command(name='check') @click.pass_context -def authorization_check(context: click.Context): +def authorization_check(context: click.Context) -> None: """Check your machine associating CLI with your cycode account""" printer = ConsolePrinter(context) @@ -43,19 +43,22 @@ def authorization_check(context: click.Context): client_id, client_secret = CredentialsManager().get_credentials() if not client_id or not client_secret: - return printer.print_result(failed_auth_check_res) + printer.print_result(failed_auth_check_res) + return try: if CycodeTokenBasedClient(client_id, client_secret).api_token: - return printer.print_result(passed_auth_check_res) + printer.print_result(passed_auth_check_res) + return except (NetworkError, HttpUnauthorizedError): if context.obj['verbose']: click.secho(f'Error: {traceback.format_exc()}', fg='red') - return printer.print_result(failed_auth_check_res) + printer.print_result(failed_auth_check_res) + return -def _handle_exception(context: click.Context, e: Exception): +def _handle_exception(context: click.Context, e: Exception) -> None: if context.obj['verbose']: click.secho(f'Error: {traceback.format_exc()}', fg='red') @@ -70,7 +73,8 @@ def _handle_exception(context: click.Context, e: Exception): error = errors.get(type(e)) if error: - return ConsolePrinter(context).print_error(error) + ConsolePrinter(context).print_error(error) + return if isinstance(e, click.ClickException): raise e diff --git a/cycode/cli/auth/auth_manager.py b/cycode/cli/auth/auth_manager.py index 3a177467..11fbf751 100644 --- a/cycode/cli/auth/auth_manager.py +++ b/cycode/cli/auth/auth_manager.py @@ -1,6 +1,6 @@ import time import webbrowser -from typing import Optional +from typing import TYPE_CHECKING, Tuple from requests import Request @@ -10,7 +10,10 @@ from cycode.cli.utils.string_utils import generate_random_string, hash_string_to_sha256 from cycode.cyclient import logger from cycode.cyclient.auth_client import AuthClient -from cycode.cyclient.models import ApiToken, ApiTokenGenerationPollingResponse +from cycode.cyclient.models import ApiTokenGenerationPollingResponse + +if TYPE_CHECKING: + from cycode.cyclient.models import ApiToken class AuthManager: @@ -20,16 +23,12 @@ class AuthManager: FAILED_POLLING_STATUS = 'Error' COMPLETED_POLLING_STATUS = 'Completed' - configuration_manager: ConfigurationManager - credentials_manager: CredentialsManager - auth_client: AuthClient - - def __init__(self): + def __init__(self) -> None: self.configuration_manager = ConfigurationManager() self.credentials_manager = CredentialsManager() self.auth_client = AuthClient() - def authenticate(self): + def authenticate(self) -> None: logger.debug('generating pkce code pair') code_challenge, code_verifier = self._generate_pkce_code_pair() @@ -46,21 +45,21 @@ def authenticate(self): logger.debug('saving get api token') self.save_api_token(api_token) - def start_session(self, code_challenge: str): + def start_session(self, code_challenge: str) -> str: auth_session = self.auth_client.start_session(code_challenge) return auth_session.session_id - def redirect_to_login_page(self, code_challenge: str, session_id: str): + def redirect_to_login_page(self, code_challenge: str, session_id: str) -> None: login_url = self._build_login_url(code_challenge, session_id) webbrowser.open(login_url) - def get_api_token(self, session_id: str, code_verifier: str) -> Optional[ApiToken]: + def get_api_token(self, session_id: str, code_verifier: str) -> 'ApiToken': api_token = self.get_api_token_polling(session_id, code_verifier) if api_token is None: raise AuthProcessError('getting api token is completed, but the token is missing') return api_token - def get_api_token_polling(self, session_id: str, code_verifier: str) -> Optional[ApiToken]: + def get_api_token_polling(self, session_id: str, code_verifier: str) -> 'ApiToken': end_polling_time = time.time() + self.POLLING_TIMEOUT_IN_SECONDS while time.time() < end_polling_time: logger.debug('trying to get api token...') @@ -75,10 +74,10 @@ def get_api_token_polling(self, session_id: str, code_verifier: str) -> Optional raise AuthProcessError('session expired') - def save_api_token(self, api_token: ApiToken): + def save_api_token(self, api_token: 'ApiToken') -> None: self.credentials_manager.update_credentials_file(api_token.client_id, api_token.secret) - def _build_login_url(self, code_challenge: str, session_id: str): + def _build_login_url(self, code_challenge: str, session_id: str) -> str: app_url = self.configuration_manager.get_cycode_app_url() login_url = f'{app_url}/account/sign-in' query_params = {'source': 'cycode_cli', 'code_challenge': code_challenge, 'session_id': session_id} @@ -86,7 +85,7 @@ def _build_login_url(self, code_challenge: str, session_id: str): request = Request(url=login_url, params=query_params) return request.prepare().url - def _generate_pkce_code_pair(self) -> (str, str): + def _generate_pkce_code_pair(self) -> Tuple[str, str]: code_verifier = generate_random_string(self.CODE_VERIFIER_LENGTH) code_challenge = hash_string_to_sha256(code_verifier) return code_challenge, code_verifier diff --git a/cycode/cli/ci_integrations.py b/cycode/cli/ci_integrations.py index 5ea93e38..f2869b2f 100644 --- a/cycode/cli/ci_integrations.py +++ b/cycode/cli/ci_integrations.py @@ -3,7 +3,7 @@ import click -def github_action_range(): +def github_action_range() -> str: before_sha = os.getenv('BEFORE_SHA') push_base_sha = os.getenv('BASE_SHA') pr_base_sha = os.getenv('PR_BASE_SHA') @@ -22,7 +22,7 @@ def github_action_range(): # if push_base_sha and push_base_sha != "null": -def circleci_range(): +def circleci_range() -> str: before_sha = os.getenv('BEFORE_SHA') current_sha = os.getenv('CURRENT_SHA') commit_range = f'{before_sha}...{current_sha}' @@ -36,7 +36,7 @@ def circleci_range(): return f'{commit_sha}~1...' -def gitlab_range(): +def gitlab_range() -> str: before_sha = os.getenv('CI_COMMIT_BEFORE_SHA') commit_sha = os.getenv('CI_COMMIT_SHA', 'HEAD') @@ -46,7 +46,7 @@ def gitlab_range(): return f'{commit_sha}' -def get_commit_range(): +def get_commit_range() -> str: if os.getenv('GITHUB_ACTIONS'): return github_action_range() if os.getenv('CIRCLECI'): diff --git a/cycode/cli/code_scanner.py b/cycode/cli/code_scanner.py index bd72ebd9..65230537 100644 --- a/cycode/cli/code_scanner.py +++ b/cycode/cli/code_scanner.py @@ -6,7 +6,7 @@ import traceback from platform import platform from sys import getsizeof -from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Callable, Dict, Iterator, List, Optional, Tuple, Union from uuid import UUID, uuid4 import click @@ -40,6 +40,10 @@ from cycode.cyclient.models import Detection, DetectionSchema, DetectionsPerFile, ZippedFileScanResult if TYPE_CHECKING: + from git import Blob, Diff + from git.objects.base import IndexObjUnion + from git.objects.tree import TraversedTreeTup + from cycode.cli.utils.progress_bar import BaseProgressBar from cycode.cyclient.models import ScanDetailsResponse from cycode.cyclient.scan_client import ScanClient @@ -58,7 +62,7 @@ required=False, ) @click.pass_context -def scan_repository(context: click.Context, path: str, branch: str): +def scan_repository(context: click.Context, path: str, branch: str) -> None: try: logger.debug('Starting repository scan process, %s', {'path': path, 'branch': branch}) @@ -74,11 +78,11 @@ def scan_repository(context: click.Context, path: str, branch: str): documents_to_scan = [] for file in file_entries: + # FIXME(MarshalX): probably file could be tree or submodule too. we expect blob only progress_bar.update(ProgressBarSection.PREPARE_LOCAL_FILES) - path = file.path if monitor else get_path_by_os(os.path.join(path, file.path)) - - documents_to_scan.append(Document(path, file.data_stream.read().decode('UTF-8', errors='replace'))) + file_path = file.path if monitor else get_path_by_os(os.path.join(path, file.path)) + documents_to_scan.append(Document(file_path, file.data_stream.read().decode('UTF-8', errors='replace'))) documents_to_scan = exclude_irrelevant_documents_to_scan(context, documents_to_scan) @@ -103,15 +107,17 @@ def scan_repository(context: click.Context, path: str, branch: str): required=False, ) @click.pass_context -def scan_repository_commit_history(context: click.Context, path: str, commit_range: str): +def scan_repository_commit_history(context: click.Context, path: str, commit_range: str) -> None: try: logger.debug('Starting commit history scan process, %s', {'path': path, 'commit_range': commit_range}) - return scan_commit_range(context, path=path, commit_range=commit_range) + scan_commit_range(context, path=path, commit_range=commit_range) except Exception as e: _handle_exception(context, e) -def scan_commit_range(context: click.Context, path: str, commit_range: str, max_commits_count: Optional[int] = None): +def scan_commit_range( + context: click.Context, path: str, commit_range: str, max_commits_count: Optional[int] = None +) -> None: scan_type = context.obj['scan_type'] progress_bar = context.obj['progress_bar'] @@ -166,7 +172,8 @@ def scan_commit_range(context: click.Context, path: str, commit_range: str, max_ logger.debug('List of commit ids to scan, %s', {'commit_ids': commit_ids_to_scan}) logger.debug('Starting to scan commit range (It may take a few minutes)') - return scan_documents(context, documents_to_scan, is_git_diff=True, is_commit_range=True) + scan_documents(context, documents_to_scan, is_git_diff=True, is_commit_range=True) + return None @click.command( @@ -174,30 +181,31 @@ def scan_commit_range(context: click.Context, path: str, commit_range: str, max_ 'CYCODE_TOKEN and CYCODE_REPO_LOCATION environment variables' ) @click.pass_context -def scan_ci(context: click.Context): - return scan_commit_range(context, path=os.getcwd(), commit_range=get_commit_range()) +def scan_ci(context: click.Context) -> None: + scan_commit_range(context, path=os.getcwd(), commit_range=get_commit_range()) @click.command(short_help='Scan the files in the path supplied in the command') @click.argument('path', nargs=1, type=click.STRING, required=True) @click.pass_context -def scan_path(context: click.Context, path): +def scan_path(context: click.Context, path: str) -> None: logger.debug('Starting path scan process, %s', {'path': path}) files_to_scan = get_relevant_files_in_path(path=path, exclude_patterns=['**/.git/**', '**/.cycode/**']) files_to_scan = exclude_irrelevant_files(context, files_to_scan) logger.debug('Found all relevant files for scanning %s', {'path': path, 'file_to_scan_count': len(files_to_scan)}) - return scan_disk_files(context, path, files_to_scan) + scan_disk_files(context, path, files_to_scan) @click.command(short_help='Use this command to scan the content that was not committed yet') @click.argument('ignored_args', nargs=-1, type=click.UNPROCESSED) @click.pass_context -def pre_commit_scan(context: click.Context, ignored_args: List[str]): +def pre_commit_scan(context: click.Context, ignored_args: List[str]) -> None: scan_type = context.obj['scan_type'] progress_bar = context.obj['progress_bar'] if scan_type == consts.SCA_SCAN_TYPE: - return scan_sca_pre_commit(context) + scan_sca_pre_commit(context) + return diff_files = Repo(os.getcwd()).index.diff('HEAD', create_patch=True, R=True) @@ -209,13 +217,13 @@ def pre_commit_scan(context: click.Context, ignored_args: List[str]): documents_to_scan.append(Document(get_path_by_os(get_diff_file_path(file)), get_diff_file_content(file))) documents_to_scan = exclude_irrelevant_documents_to_scan(context, documents_to_scan) - return scan_documents(context, documents_to_scan, is_git_diff=True) + scan_documents(context, documents_to_scan, is_git_diff=True) @click.command(short_help='Use this command to scan commits on the server side before pushing them to the repository') @click.argument('ignored_args', nargs=-1, type=click.UNPROCESSED) @click.pass_context -def pre_receive_scan(context: click.Context, ignored_args: List[str]): +def pre_receive_scan(context: click.Context, ignored_args: List[str]) -> None: try: scan_type = context.obj['scan_type'] if scan_type != consts.SECRET_SCAN_TYPE: @@ -253,13 +261,13 @@ def pre_receive_scan(context: click.Context, ignored_args: List[str]): _handle_exception(context, e) -def scan_sca_pre_commit(context: click.Context): +def scan_sca_pre_commit(context: click.Context) -> None: scan_parameters = get_default_scan_parameters(context) git_head_documents, pre_committed_documents = get_pre_commit_modified_documents(context.obj['progress_bar']) git_head_documents = exclude_irrelevant_documents_to_scan(context, git_head_documents) pre_committed_documents = exclude_irrelevant_documents_to_scan(context, pre_committed_documents) sca_code_scanner.perform_pre_hook_range_scan_actions(git_head_documents, pre_committed_documents) - return scan_commit_range_documents( + scan_commit_range_documents( context, git_head_documents, pre_committed_documents, @@ -268,7 +276,7 @@ def scan_sca_pre_commit(context: click.Context): ) -def scan_sca_commit_range(context: click.Context, path: str, commit_range: str): +def scan_sca_commit_range(context: click.Context, path: str, commit_range: str) -> None: progress_bar = context.obj['progress_bar'] scan_parameters = get_scan_parameters(context, path) @@ -282,12 +290,10 @@ def scan_sca_commit_range(context: click.Context, path: str, commit_range: str): path, from_commit_documents, from_commit_rev, to_commit_documents, to_commit_rev ) - return scan_commit_range_documents( - context, from_commit_documents, to_commit_documents, scan_parameters=scan_parameters - ) + scan_commit_range_documents(context, from_commit_documents, to_commit_documents, scan_parameters=scan_parameters) -def scan_disk_files(context: click.Context, path: str, files_to_scan: List[str]): +def scan_disk_files(context: click.Context, path: str, files_to_scan: List[str]) -> None: scan_parameters = get_scan_parameters(context, path) scan_type = context.obj['scan_type'] progress_bar = context.obj['progress_bar'] @@ -307,7 +313,7 @@ def scan_disk_files(context: click.Context, path: str, files_to_scan: List[str]) continue perform_pre_scan_documents_actions(context, scan_type, documents, is_git_diff) - return scan_documents(context, documents, is_git_diff=is_git_diff, scan_parameters=scan_parameters) + scan_documents(context, documents, is_git_diff=is_git_diff, scan_parameters=scan_parameters) def set_issue_detected_by_scan_results(context: click.Context, scan_results: List[LocalScanResult]) -> None: @@ -757,19 +763,21 @@ def get_oldest_unupdated_commit_for_branch(commit: str) -> Optional[str]: return commits[0] -def get_diff_file_path(file): +def get_diff_file_path(file: 'Diff') -> Optional[str]: return file.b_path if file.b_path else file.a_path -def get_diff_file_content(file): +def get_diff_file_content(file: 'Diff') -> str: return file.diff.decode('UTF-8', errors='replace') -def should_process_git_object(obj, _: int) -> bool: +def should_process_git_object(obj: 'Blob', _: int) -> bool: return obj.type == 'blob' and obj.size > 0 -def get_git_repository_tree_file_entries(path: str, branch: str): +def get_git_repository_tree_file_entries( + path: str, branch: str +) -> Union[Iterator['IndexObjUnion'], Iterator['TraversedTreeTup']]: return Repo(path).tree(branch).traverse(predicate=should_process_git_object) @@ -867,7 +875,7 @@ def _exclude_detections_by_scan_type( return detections -def exclude_detections_in_deleted_lines(detections) -> List: +def exclude_detections_in_deleted_lines(detections: List[Detection]) -> List[Detection]: return [detection for detection in detections if detection.detection_details.get('line_type') != 'Removed'] @@ -969,7 +977,7 @@ def _should_exclude_detection(detection: Detection, exclusions: Dict) -> bool: return False -def _is_detection_sha_configured_in_exclusions(detection, exclusions: List[str]) -> bool: +def _is_detection_sha_configured_in_exclusions(detection: Detection, exclusions: List[str]) -> bool: detection_sha = detection.detection_details.get('sha512', '') return detection_sha in exclusions diff --git a/cycode/cli/exceptions/custom_exceptions.py b/cycode/cli/exceptions/custom_exceptions.py index 8ae2236f..4806e17c 100644 --- a/cycode/cli/exceptions/custom_exceptions.py +++ b/cycode/cli/exceptions/custom_exceptions.py @@ -6,13 +6,13 @@ class CycodeError(Exception): class NetworkError(CycodeError): - def __init__(self, status_code: int, error_message: str, response: Response): + def __init__(self, status_code: int, error_message: str, response: Response) -> None: self.status_code = status_code self.error_message = error_message self.response = response super().__init__(self.error_message) - def __str__(self): + def __str__(self) -> str: return ( f'error occurred during the request. status code: {self.status_code}, error message: ' f'{self.error_message}' @@ -20,38 +20,38 @@ def __str__(self): class ScanAsyncError(CycodeError): - def __init__(self, error_message: str): + def __init__(self, error_message: str) -> None: self.error_message = error_message super().__init__(self.error_message) - def __str__(self): + def __str__(self) -> str: return f'error occurred during the scan. error message: {self.error_message}' class HttpUnauthorizedError(CycodeError): - def __init__(self, error_message: str, response: Response): + def __init__(self, error_message: str, response: Response) -> None: self.status_code = 401 self.error_message = error_message self.response = response super().__init__(self.error_message) - def __str__(self): + def __str__(self) -> str: return 'Http Unauthorized Error' class ZipTooLargeError(CycodeError): - def __init__(self, size_limit: int): + def __init__(self, size_limit: int) -> None: self.size_limit = size_limit super().__init__() - def __str__(self): + def __str__(self) -> str: return f'The size of zip to scan is too large, size limit: {self.size_limit}' class AuthProcessError(CycodeError): - def __init__(self, error_message: str): + def __init__(self, error_message: str) -> None: self.error_message = error_message super().__init__() - def __str__(self): + def __str__(self) -> str: return f'Something went wrong during the authentication process, error message: {self.error_message}' diff --git a/cycode/cli/helpers/maven/base_restore_maven_dependencies.py b/cycode/cli/helpers/maven/base_restore_maven_dependencies.py index b3c55008..064b9eeb 100644 --- a/cycode/cli/helpers/maven/base_restore_maven_dependencies.py +++ b/cycode/cli/helpers/maven/base_restore_maven_dependencies.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Dict, List, Optional +from typing import List, Optional import click @@ -13,7 +13,7 @@ def build_dep_tree_path(path: str, generated_file_name: str) -> str: return join_paths(get_file_dir(path), generated_file_name) -def execute_command(command: List[str], file_name: str, command_timeout: int) -> Optional[Dict]: +def execute_command(command: List[str], file_name: str, command_timeout: int) -> Optional[str]: try: dependencies = shell(command, command_timeout) except Exception as e: @@ -24,7 +24,7 @@ def execute_command(command: List[str], file_name: str, command_timeout: int) -> class BaseRestoreMavenDependencies(ABC): - def __init__(self, context: click.Context, is_git_diff: bool, command_timeout: int): + def __init__(self, context: click.Context, is_git_diff: bool, command_timeout: int) -> None: self.context = context self.is_git_diff = is_git_diff self.command_timeout = command_timeout diff --git a/cycode/cli/helpers/maven/restore_gradle_dependencies.py b/cycode/cli/helpers/maven/restore_gradle_dependencies.py index 6a60faad..f8cd2fec 100644 --- a/cycode/cli/helpers/maven/restore_gradle_dependencies.py +++ b/cycode/cli/helpers/maven/restore_gradle_dependencies.py @@ -11,7 +11,7 @@ class RestoreGradleDependencies(BaseRestoreMavenDependencies): - def __init__(self, context: click.Context, is_git_diff: bool, command_timeout: int): + def __init__(self, context: click.Context, is_git_diff: bool, command_timeout: int) -> None: super().__init__(context, is_git_diff, command_timeout) def is_project(self, document: Document) -> bool: diff --git a/cycode/cli/helpers/maven/restore_maven_dependencies.py b/cycode/cli/helpers/maven/restore_maven_dependencies.py index bef8a1b1..d8e6675f 100644 --- a/cycode/cli/helpers/maven/restore_maven_dependencies.py +++ b/cycode/cli/helpers/maven/restore_maven_dependencies.py @@ -17,7 +17,7 @@ class RestoreMavenDependencies(BaseRestoreMavenDependencies): - def __init__(self, context: click.Context, is_git_diff: bool, command_timeout: int): + def __init__(self, context: click.Context, is_git_diff: bool, command_timeout: int) -> None: super().__init__(context, is_git_diff, command_timeout) def is_project(self, document: Document) -> bool: @@ -43,8 +43,10 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: return restore_dependencies_document - def restore_from_secondary_command(self, document, manifest_file_path, restore_dependencies_document): - # TODO(MarshalX): does it even work? Missing argument + def restore_from_secondary_command( + self, document: Document, manifest_file_path: str, restore_dependencies_document: Optional[Document] + ) -> Optional[Document]: + # TODO(MarshalX): does it even work? Ignored restore_dependencies_document arg secondary_restore_command = create_secondary_restore_command(manifest_file_path) backup_restore_content = execute_command(secondary_restore_command, manifest_file_path, self.command_timeout) restore_dependencies_document = Document( @@ -58,7 +60,7 @@ def restore_from_secondary_command(self, document, manifest_file_path, restore_d return restore_dependencies -def create_secondary_restore_command(self, manifest_file_path): +def create_secondary_restore_command(manifest_file_path: str) -> List[str]: return [ 'mvn', 'dependency:tree', diff --git a/cycode/cli/helpers/sca_code_scanner.py b/cycode/cli/helpers/sca_code_scanner.py index 87e301e3..ccff11b0 100644 --- a/cycode/cli/helpers/sca_code_scanner.py +++ b/cycode/cli/helpers/sca_code_scanner.py @@ -1,5 +1,5 @@ import os -from typing import Dict, List, Optional +from typing import TYPE_CHECKING, Dict, List, Optional import click from git import GitCommandError, Repo @@ -11,6 +11,9 @@ from cycode.cli.utils.path_utils import get_file_content, get_file_dir, join_paths from cycode.cyclient import logger +if TYPE_CHECKING: + from cycode.cli.helpers.maven.base_restore_maven_dependencies import BaseRestoreMavenDependencies + BUILD_GRADLE_FILE_NAME = 'build.gradle' BUILD_GRADLE_KTS_FILE_NAME = 'build.gradle.kts' BUILD_GRADLE_DEP_TREE_FILE_NAME = 'gradle-dependencies-generated.txt' @@ -39,7 +42,7 @@ def perform_pre_hook_range_scan_actions( def add_ecosystem_related_files_if_exists( documents: List[Document], repo: Optional[Repo] = None, commit_rev: Optional[str] = None -): +) -> None: for doc in documents: ecosystem = get_project_file_ecosystem(doc) if ecosystem is None: @@ -81,8 +84,11 @@ def get_project_file_ecosystem(document: Document) -> Optional[str]: def try_restore_dependencies( - context: click.Context, documents_to_add: List[Document], restore_dependencies, document: Document -): + context: click.Context, + documents_to_add: Dict[str, Document], + restore_dependencies: 'BaseRestoreMavenDependencies', + document: Document, +) -> None: if restore_dependencies.is_project(document): restore_dependencies_document = restore_dependencies.restore(document) if restore_dependencies_document is None: @@ -117,7 +123,7 @@ def add_dependencies_tree_document( documents_to_scan.extend(list(documents_to_add.values())) -def restore_handlers(context, is_git_diff): +def restore_handlers(context: click.Context, is_git_diff: bool) -> List[RestoreGradleDependencies]: return [ RestoreGradleDependencies(context, is_git_diff, BUILD_GRADLE_DEP_TREE_TIMEOUT), RestoreMavenDependencies(context, is_git_diff, BUILD_GRADLE_DEP_TREE_TIMEOUT), diff --git a/cycode/cli/main.py b/cycode/cli/main.py index cb741ad7..0086534e 100644 --- a/cycode/cli/main.py +++ b/cycode/cli/main.py @@ -1,6 +1,6 @@ import logging import sys -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING, List, Optional, Tuple import click @@ -120,17 +120,17 @@ @click.pass_context def code_scan( context: click.Context, - scan_type, - client_id, - secret, - show_secret, - soft_fail, - output, - severity_threshold, + scan_type: str, + secret: str, + client_id: str, + show_secret: bool, + soft_fail: bool, + output: str, + severity_threshold: str, sca_scan: List[str], - monitor, - report, -): + monitor: bool, + report: bool, +) -> int: if show_secret: context.obj['show_secret'] = show_secret else: @@ -164,7 +164,7 @@ def code_scan( @code_scan.result_callback() @click.pass_context -def finalize(context: click.Context, *_, **__): +def finalize(context: click.Context, *_, **__) -> None: progress_bar = context.obj.get('progress_bar') if progress_bar: progress_bar.stop() @@ -210,7 +210,9 @@ def finalize(context: click.Context, *_, **__): ) @click.version_option(__version__, prog_name='cycode') @click.pass_context -def main_cli(context: click.Context, verbose: bool, no_progress_meter: bool, output: str, user_agent: Optional[str]): +def main_cli( + context: click.Context, verbose: bool, no_progress_meter: bool, output: str, user_agent: Optional[str] +) -> None: context.ensure_object(dict) configuration_manager = ConfigurationManager() @@ -243,16 +245,16 @@ def get_cycode_client(client_id: str, client_secret: str) -> 'ScanClient': return create_scan_client(client_id, client_secret) -def _get_configured_credentials(): +def _get_configured_credentials() -> Tuple[str, str]: credentials_manager = CredentialsManager() return credentials_manager.get_credentials() -def _should_fail_scan(context: click.Context): +def _should_fail_scan(context: click.Context) -> bool: return scan_utils.is_scan_failed(context) -def _sca_scan_to_context(context: click.Context, sca_scan_user_selected: List[str]): +def _sca_scan_to_context(context: click.Context, sca_scan_user_selected: List[str]) -> None: for sca_scan_option_selected in sca_scan_user_selected: context.obj[sca_scan_option_selected] = True diff --git a/cycode/cli/models.py b/cycode/cli/models.py index 67453b8c..05713934 100644 --- a/cycode/cli/models.py +++ b/cycode/cli/models.py @@ -5,7 +5,9 @@ class Document: - def __init__(self, path: str, content: str, is_git_diff_format: bool = False, unique_id: Optional[str] = None): + def __init__( + self, path: str, content: str, is_git_diff_format: bool = False, unique_id: Optional[str] = None + ) -> None: self.path = path self.content = content self.is_git_diff_format = is_git_diff_format @@ -16,7 +18,7 @@ def __repr__(self) -> str: class DocumentDetections: - def __init__(self, document: Document, detections: List[Detection]): + def __init__(self, document: Document, detections: List[Detection]) -> None: self.document = document self.detections = detections diff --git a/cycode/cli/printers/base_printer.py b/cycode/cli/printers/base_printer.py index 7b094c65..ceb430ab 100644 --- a/cycode/cli/printers/base_printer.py +++ b/cycode/cli/printers/base_printer.py @@ -14,7 +14,7 @@ class BasePrinter(ABC): WHITE_COLOR_NAME = 'white' GREEN_COLOR_NAME = 'green' - def __init__(self, context: click.Context): + def __init__(self, context: click.Context) -> None: self.context = context @abstractmethod diff --git a/cycode/cli/printers/base_table_printer.py b/cycode/cli/printers/base_table_printer.py index 56a1a552..f304f967 100644 --- a/cycode/cli/printers/base_table_printer.py +++ b/cycode/cli/printers/base_table_printer.py @@ -12,7 +12,7 @@ class BaseTablePrinter(BasePrinter, abc.ABC): - def __init__(self, context: click.Context): + def __init__(self, context: click.Context) -> None: super().__init__(context) self.context = context self.scan_type: str = context.obj.get('scan_type') @@ -24,7 +24,7 @@ def print_result(self, result: CliResult) -> None: def print_error(self, error: CliError) -> None: TextPrinter(self.context).print_error(error) - def print_scan_results(self, local_scan_results: List['LocalScanResult']): + def print_scan_results(self, local_scan_results: List['LocalScanResult']) -> None: if all(result.issue_detected == 0 for result in local_scan_results): click.secho('Good job! No issues were found!!! 👏👏👏', fg=self.GREEN_COLOR_NAME) return diff --git a/cycode/cli/printers/console_printer.py b/cycode/cli/printers/console_printer.py index 2321f089..533ed321 100644 --- a/cycode/cli/printers/console_printer.py +++ b/cycode/cli/printers/console_printer.py @@ -24,7 +24,7 @@ class ConsolePrinter: 'text_sca': SCATablePrinter, } - def __init__(self, context: click.Context): + def __init__(self, context: click.Context) -> None: self.context = context self.scan_type = self.context.obj.get('scan_type') self.output_type = self.context.obj.get('output') diff --git a/cycode/cli/printers/sca_table_printer.py b/cycode/cli/printers/sca_table_printer.py index b3b03567..80d2da4f 100644 --- a/cycode/cli/printers/sca_table_printer.py +++ b/cycode/cli/printers/sca_table_printer.py @@ -84,7 +84,9 @@ def _get_table_headers(self) -> list: return [] - def _print_table_detections(self, detections: List[Detection], headers: List[str], rows, title: str) -> None: + def _print_table_detections( + self, detections: List[Detection], headers: List[str], rows: List[List[str]], title: str + ) -> None: self._print_summary_issues(detections, title) text_table = Texttable() text_table.header(headers) diff --git a/cycode/cli/printers/table.py b/cycode/cli/printers/table.py index d2847399..9c90c940 100644 --- a/cycode/cli/printers/table.py +++ b/cycode/cli/printers/table.py @@ -9,7 +9,7 @@ class Table: """Helper class to manage columns and their values in the right order and only if the column should be presented.""" - def __init__(self, column_infos: Optional[List['ColumnInfo']] = None): + def __init__(self, column_infos: Optional[List['ColumnInfo']] = None) -> None: self._column_widths = None self._columns: Dict['ColumnInfo', List[str]] = {} diff --git a/cycode/cli/printers/text_printer.py b/cycode/cli/printers/text_printer.py index 4555225d..d06021a3 100644 --- a/cycode/cli/printers/text_printer.py +++ b/cycode/cli/printers/text_printer.py @@ -14,7 +14,7 @@ class TextPrinter(BasePrinter): - def __init__(self, context: click.Context): + def __init__(self, context: click.Context) -> None: super().__init__(context) self.scan_type: str = context.obj.get('scan_type') self.command_scan_type: str = context.info_name @@ -30,7 +30,7 @@ def print_result(self, result: CliResult) -> None: def print_error(self, error: CliError) -> None: click.secho(error.message, fg=self.RED_COLOR_NAME) - def print_scan_results(self, local_scan_results: List['LocalScanResult']): + def print_scan_results(self, local_scan_results: List['LocalScanResult']) -> None: if all(result.issue_detected == 0 for result in local_scan_results): click.secho('Good job! No issues were found!!! 👏👏👏', fg=self.GREEN_COLOR_NAME) return @@ -43,7 +43,7 @@ def print_scan_results(self, local_scan_results: List['LocalScanResult']): def _print_document_detections( self, document_detections: DocumentDetections, scan_id: str, report_url: Optional[str] - ): + ) -> None: document = document_detections.document lines_to_display = self._get_lines_to_display_count() for detection in document_detections.detections: @@ -52,7 +52,7 @@ def _print_document_detections( def _print_detection_summary( self, detection: Detection, document_path: str, scan_id: str, report_url: Optional[str] - ): + ) -> None: detection_name = detection.type if self.scan_type == SECRET_SCAN_TYPE else detection.message detection_sha = detection.detection_details.get('sha512') @@ -70,14 +70,15 @@ def _print_detection_summary( f'{detection_sha_message}{scan_id_message}{report_url_message}{detection_commit_id_message} ⛔' ) - def _print_detection_code_segment(self, detection: Detection, document: Document, code_segment_size: int): + def _print_detection_code_segment(self, detection: Detection, document: Document, code_segment_size: int) -> None: if self._is_git_diff_based_scan(): self._print_detection_from_git_diff(detection, document) return self._print_detection_from_file(detection, document, code_segment_size) - def _get_code_segment_start_line(self, detection_line: int, code_segment_size: int): + @staticmethod + def _get_code_segment_start_line(detection_line: int, code_segment_size: int) -> int: start_line = detection_line - math.ceil(code_segment_size / 2) return 0 if start_line < 0 else start_line @@ -89,7 +90,7 @@ def _print_line_of_code_segment( detection_position_in_line: int, violation_length: int, is_detection_line: bool, - ): + ) -> None: if is_detection_line: self._print_detection_line(document, line, line_number, detection_position_in_line, violation_length) else: @@ -104,7 +105,7 @@ def _print_detection_line( click.echo(f'{self._get_line_number_style(line_number)} {detection_line}') - def _print_line(self, document: Document, line: str, line_number: int): + def _print_line(self, document: Document, line: str, line_number: int) -> None: line_no = self._get_line_number_style(line_number) line = self._get_line_style(line, document.is_git_diff_format) @@ -148,7 +149,7 @@ def _get_line_color(self, line: str, is_git_diff: bool) -> str: return self.WHITE_COLOR_NAME - def _get_line_number_style(self, line_number: int): + def _get_line_number_style(self, line_number: int) -> str: return ( f'{click.style(str(line_number), fg=self.WHITE_COLOR_NAME, bold=False)} ' f'{click.style("|", fg=self.RED_COLOR_NAME, bold=False)}' @@ -164,7 +165,7 @@ def _get_lines_to_display_count(self) -> int: return result_printer_configuration.get('default').get('lines_to_display') - def _print_detection_from_file(self, detection: Detection, document: Document, code_segment_size: int): + def _print_detection_from_file(self, detection: Detection, document: Document, code_segment_size: int) -> None: detection_details = detection.detection_details detection_line = ( detection_details.get('line', -1) @@ -197,7 +198,7 @@ def _print_detection_from_file(self, detection: Detection, document: Document, c ) click.echo() - def _print_detection_from_git_diff(self, detection: Detection, document: Document): + def _print_detection_from_git_diff(self, detection: Detection, document: Document) -> None: detection_details = detection.detection_details detection_line_number = detection_details.get('line', -1) detection_line_number_in_original_file = detection_details.get('line_in_file', -1) @@ -219,5 +220,5 @@ def _print_detection_from_git_diff(self, detection: Detection, document: Documen ) click.echo() - def _is_git_diff_based_scan(self): + def _is_git_diff_based_scan(self) -> bool: return self.command_scan_type in COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES and self.scan_type == SECRET_SCAN_TYPE diff --git a/cycode/cli/user_settings/base_file_manager.py b/cycode/cli/user_settings/base_file_manager.py index a640d8fa..37c9b0de 100644 --- a/cycode/cli/user_settings/base_file_manager.py +++ b/cycode/cli/user_settings/base_file_manager.py @@ -1,21 +1,22 @@ import os from abc import ABC, abstractmethod +from typing import Any, Dict, Hashable from cycode.cli.utils.yaml_utils import read_file, update_file class BaseFileManager(ABC): @abstractmethod - def get_filename(self): - pass + def get_filename(self) -> str: + ... - def read_file(self): + def read_file(self) -> Dict[Hashable, Any]: try: return read_file(self.get_filename()) except FileNotFoundError: return {} - def write_content_to_file(self, content): + def write_content_to_file(self, content: Dict[Hashable, Any]) -> None: filename = self.get_filename() os.makedirs(os.path.dirname(filename), exist_ok=True) update_file(filename, content) diff --git a/cycode/cli/user_settings/config_file_manager.py b/cycode/cli/user_settings/config_file_manager.py index 5d2e3016..acec4d17 100644 --- a/cycode/cli/user_settings/config_file_manager.py +++ b/cycode/cli/user_settings/config_file_manager.py @@ -1,9 +1,12 @@ import os -from typing import Dict, List, Optional +from typing import TYPE_CHECKING, Any, Dict, Hashable, List, Optional, Union from cycode.cli.consts import CYCODE_CONFIGURATION_DIRECTORY from cycode.cli.user_settings.base_file_manager import BaseFileManager +if TYPE_CHECKING: + from pathlib import Path + class ConfigFileManager(BaseFileManager): CYCODE_HIDDEN_DIRECTORY: str = CYCODE_CONFIGURATION_DIRECTORY @@ -22,34 +25,34 @@ class ConfigFileManager(BaseFileManager): COMMAND_TIMEOUT_FIELD_NAME: str = 'command_timeout' EXCLUDE_DETECTIONS_IN_DELETED_LINES: str = 'exclude_detections_in_deleted_lines' - def __init__(self, path): + def __init__(self, path: Union['Path', str]) -> None: self.path = path - def get_api_url(self) -> Optional[str]: + def get_api_url(self) -> Optional[Any]: return self._get_value_from_environment_section(self.API_URL_FIELD_NAME) - def get_app_url(self) -> Optional[str]: + def get_app_url(self) -> Optional[Any]: return self._get_value_from_environment_section(self.APP_URL_FIELD_NAME) - def get_verbose_flag(self) -> Optional[bool]: + def get_verbose_flag(self) -> Optional[Any]: return self._get_value_from_environment_section(self.VERBOSE_FIELD_NAME) - def get_exclusions_by_scan_type(self, scan_type) -> Dict: + def get_exclusions_by_scan_type(self, scan_type: str) -> Dict[Hashable, Any]: exclusions_section = self._get_section(self.EXCLUSIONS_SECTION_NAME) return exclusions_section.get(scan_type, {}) - def get_max_commits(self, command_scan_type) -> Optional[int]: + def get_max_commits(self, command_scan_type: str) -> Optional[Any]: return self._get_value_from_command_scan_type_configuration(command_scan_type, self.MAX_COMMITS_FIELD_NAME) - def get_command_timeout(self, command_scan_type) -> Optional[int]: + def get_command_timeout(self, command_scan_type: str) -> Optional[Any]: return self._get_value_from_command_scan_type_configuration(command_scan_type, self.COMMAND_TIMEOUT_FIELD_NAME) - def get_exclude_detections_in_deleted_lines(self, command_scan_type) -> Optional[bool]: + def get_exclude_detections_in_deleted_lines(self, command_scan_type: str) -> Optional[Any]: return self._get_value_from_command_scan_type_configuration( command_scan_type, self.EXCLUDE_DETECTIONS_IN_DELETED_LINES ) - def update_base_url(self, base_url: str): + def update_base_url(self, base_url: str) -> None: update_data = {self.ENVIRONMENT_SECTION_NAME: {self.API_URL_FIELD_NAME: base_url}} self.write_content_to_file(update_data) @@ -60,7 +63,7 @@ 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 add_exclusion(self, scan_type, exclusion_type, new_exclusion): + 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: return @@ -80,22 +83,22 @@ def get_filename(self) -> str: def get_config_file_route() -> str: return os.path.join(ConfigFileManager.CYCODE_HIDDEN_DIRECTORY, ConfigFileManager.FILE_NAME) - def _get_exclusions_by_exclusion_type(self, scan_type, exclusion_type) -> List: + def _get_exclusions_by_exclusion_type(self, scan_type: str, exclusion_type: str) -> List[Any]: scan_type_exclusions = self.get_exclusions_by_scan_type(scan_type) return scan_type_exclusions.get(exclusion_type, []) - def _get_value_from_environment_section(self, field_name: str): + def _get_value_from_environment_section(self, field_name: str) -> Optional[Any]: environment_section = self._get_section(self.ENVIRONMENT_SECTION_NAME) return environment_section.get(field_name) - def _get_scan_configuration_by_scan_type(self, command_scan_type: str) -> Dict: + def _get_scan_configuration_by_scan_type(self, command_scan_type: str) -> Dict[Hashable, Any]: scan_section = self._get_section(self.SCAN_SECTION_NAME) return scan_section.get(command_scan_type, {}) - def _get_value_from_command_scan_type_configuration(self, command_scan_type: str, field_name: str): + def _get_value_from_command_scan_type_configuration(self, command_scan_type: str, field_name: str) -> Optional[Any]: command_scan_type_configuration = self._get_scan_configuration_by_scan_type(command_scan_type) return command_scan_type_configuration.get(field_name) - def _get_section(self, section_name: str) -> Dict: + def _get_section(self, section_name: str) -> Dict[Hashable, Any]: file_content = self.read_file() return file_content.get(section_name, {}) diff --git a/cycode/cli/user_settings/configuration_manager.py b/cycode/cli/user_settings/configuration_manager.py index f93759c9..947781da 100644 --- a/cycode/cli/user_settings/configuration_manager.py +++ b/cycode/cli/user_settings/configuration_manager.py @@ -1,6 +1,6 @@ import os from pathlib import Path -from typing import Dict, Optional +from typing import Any, Dict, Optional from uuid import uuid4 from cycode.cli import consts @@ -11,7 +11,7 @@ class ConfigurationManager: global_config_file_manager: ConfigFileManager local_config_file_manager: ConfigFileManager - def __init__(self): + def __init__(self) -> None: self.global_config_file_manager = ConfigFileManager(Path.home()) self.local_config_file_manager = ConfigFileManager(os.getcwd()) @@ -61,12 +61,12 @@ def get_verbose_flag_from_environment_variables(self) -> bool: value = self._get_value_from_environment_variables(consts.VERBOSE_ENV_VAR_NAME, '') return value.lower() in ('true', '1') - def get_exclusions_by_scan_type(self, scan_type) -> Dict: + def get_exclusions_by_scan_type(self, scan_type: str) -> Dict: local_exclusions = self.local_config_file_manager.get_exclusions_by_scan_type(scan_type) global_exclusions = self.global_config_file_manager.get_exclusions_by_scan_type(scan_type) return self._merge_exclusions(local_exclusions, global_exclusions) - def add_exclusion(self, scope: str, scan_type: str, exclusion_type: str, value: str): + def add_exclusion(self, scope: str, scan_type: str, exclusion_type: str, value: str) -> None: config_file_manager = self.get_config_file_manager(scope) config_file_manager.add_exclusion(scan_type, exclusion_type, value) @@ -74,7 +74,7 @@ def _merge_exclusions(self, local_exclusions: Dict, global_exclusions: Dict) -> keys = set(list(local_exclusions.keys()) + list(global_exclusions.keys())) return {key: local_exclusions.get(key, []) + global_exclusions.get(key, []) for key in keys} - def update_base_url(self, base_url: str, scope: str = 'local'): + def update_base_url(self, base_url: str, scope: str = 'local') -> None: config_file_manager = self.get_config_file_manager(scope) config_file_manager.update_base_url(base_url) @@ -162,5 +162,5 @@ def get_should_exclude_detections_in_deleted_lines(self, command_scan_type: str) return consts.DEFAULT_EXCLUDE_DETECTIONS_IN_DELETED_LINES @staticmethod - def _get_value_from_environment_variables(env_var_name, default=None): + def _get_value_from_environment_variables(env_var_name: str, default: Optional[Any] = None) -> Optional[Any]: return os.getenv(env_var_name, default) diff --git a/cycode/cli/user_settings/credentials_manager.py b/cycode/cli/user_settings/credentials_manager.py index 0ec0e6e8..02653f6d 100644 --- a/cycode/cli/user_settings/credentials_manager.py +++ b/cycode/cli/user_settings/credentials_manager.py @@ -1,5 +1,6 @@ import os from pathlib import Path +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.user_settings.base_file_manager import BaseFileManager @@ -13,19 +14,20 @@ class CredentialsManager(BaseFileManager): CLIENT_ID_FIELD_NAME: str = 'cycode_client_id' CLIENT_SECRET_FIELD_NAME: str = 'cycode_client_secret' - def get_credentials(self) -> (str, str): + def get_credentials(self) -> Tuple[str, str]: client_id, client_secret = self.get_credentials_from_environment_variables() if client_id is not None and client_secret is not None: return client_id, client_secret return self.get_credentials_from_file() - def get_credentials_from_environment_variables(self) -> (str, str): + @staticmethod + def get_credentials_from_environment_variables() -> Tuple[str, str]: client_id = os.getenv(CYCODE_CLIENT_ID_ENV_VAR_NAME) client_secret = os.getenv(CYCODE_CLIENT_SECRET_ENV_VAR_NAME) return client_id, client_secret - def get_credentials_from_file(self) -> (str, str): + def get_credentials_from_file(self) -> Tuple[Optional[str], Optional[str]]: credentials_filename = self.get_filename() try: file_content = read_file(credentials_filename) @@ -36,7 +38,7 @@ def get_credentials_from_file(self) -> (str, str): client_secret = file_content.get(self.CLIENT_SECRET_FIELD_NAME) return client_id, client_secret - def update_credentials_file(self, client_id: str, client_secret: str): + def update_credentials_file(self, client_id: str, client_secret: str) -> None: credentials = {self.CLIENT_ID_FIELD_NAME: client_id, self.CLIENT_SECRET_FIELD_NAME: client_secret} self.get_filename() diff --git a/cycode/cli/user_settings/user_settings_commands.py b/cycode/cli/user_settings/user_settings_commands.py index f4fb5bee..500b13db 100644 --- a/cycode/cli/user_settings/user_settings_commands.py +++ b/cycode/cli/user_settings/user_settings_commands.py @@ -24,7 +24,7 @@ @click.command( short_help='Initial command to authenticate your CLI client with Cycode using client ID and client secret' ) -def set_credentials(): +def set_credentials() -> None: click.echo(f'Update credentials in file ({credentials_manager.get_filename()})') current_client_id, current_client_secret = credentials_manager.get_credentials_from_file() client_id = _get_client_id_input(current_client_id) @@ -85,7 +85,7 @@ def set_credentials(): ) def add_exclusions( by_value: str, by_sha: str, by_path: str, by_rule: str, by_package: str, scan_type: str, is_global: bool -): +) -> None: """Ignore a specific value, path or rule ID""" 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') @@ -143,14 +143,14 @@ def _get_client_secret_input(current_client_secret: str) -> str: return new_client_secret if new_client_secret else current_client_secret -def _get_credentials_update_result_message(): +def _get_credentials_update_result_message() -> str: if not _are_credentials_exist_in_environment_variables(): return CREDENTIALS_UPDATED_SUCCESSFULLY_MESSAGE return CREDENTIALS_UPDATED_SUCCESSFULLY_MESSAGE + ' ' + CREDENTIALS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE -def _are_credentials_exist_in_environment_variables(): +def _are_credentials_exist_in_environment_variables() -> bool: client_id, client_secret = credentials_manager.get_credentials_from_environment_variables() return client_id is not None or client_secret is not None diff --git a/cycode/cli/utils/enum_utils.py b/cycode/cli/utils/enum_utils.py index 54a02ffb..6ea9ef72 100644 --- a/cycode/cli/utils/enum_utils.py +++ b/cycode/cli/utils/enum_utils.py @@ -1,7 +1,8 @@ from enum import Enum +from typing import List class AutoCountEnum(Enum): @staticmethod - def _generate_next_value_(name, start, count, last_values): + def _generate_next_value_(name: str, start: int, count: int, last_values: List[int]) -> int: return count diff --git a/cycode/cli/utils/path_utils.py b/cycode/cli/utils/path_utils.py index 3522807a..e25daede 100644 --- a/cycode/cli/utils/path_utils.py +++ b/cycode/cli/utils/path_utils.py @@ -1,6 +1,6 @@ import os from pathlib import Path -from typing import AnyStr, Iterable, List, Optional +from typing import AnyStr, Generator, Iterable, List, Optional import pathspec from binaryornot.check import is_binary @@ -49,12 +49,12 @@ def get_path_by_os(filename: str) -> str: return filename.replace('/', os.sep) -def _get_all_existing_files_in_directory(path: str): +def _get_all_existing_files_in_directory(path: str) -> Generator[Path, None, None]: directory = Path(path) return directory.rglob(r'*') -def is_path_exists(path: str): +def is_path_exists(path: str) -> bool: return os.path.exists(path) diff --git a/cycode/cli/utils/progress_bar.py b/cycode/cli/utils/progress_bar.py index 38e00984..e340fb92 100644 --- a/cycode/cli/utils/progress_bar.py +++ b/cycode/cli/utils/progress_bar.py @@ -9,6 +9,7 @@ if TYPE_CHECKING: from click._termui_impl import ProgressBar + from click.termui import V as ProgressBarValue logger = get_logger('progress bar') @@ -57,7 +58,7 @@ def _get_section_length(section: 'ProgressBarSection') -> int: class BaseProgressBar(ABC): @abstractmethod - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: pass @abstractmethod @@ -86,7 +87,7 @@ def update(self, section: 'ProgressBarSection') -> None: class DummyProgressBar(BaseProgressBar): - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) def __enter__(self) -> 'DummyProgressBar': @@ -109,7 +110,7 @@ def update(self, section: 'ProgressBarSection') -> None: class CompositeProgressBar(BaseProgressBar): - def __init__(self): + def __init__(self) -> None: super().__init__() self._progress_bar_context_manager = click.progressbar( length=_PROGRESS_BAR_LENGTH, @@ -192,7 +193,7 @@ def _get_increment_progress_value(self, section: 'ProgressBarSection') -> int: return expected_value - self._current_section_value - def _progress_bar_item_show_func(self, _=None) -> str: + def _progress_bar_item_show_func(self, _: Optional['ProgressBarValue'] = None) -> str: return self._current_section.label def update(self, section: 'ProgressBarSection', value: int = 1) -> None: diff --git a/cycode/cli/utils/scan_batch.py b/cycode/cli/utils/scan_batch.py index 0f47c30c..4c839440 100644 --- a/cycode/cli/utils/scan_batch.py +++ b/cycode/cli/utils/scan_batch.py @@ -44,7 +44,8 @@ def split_documents_into_batches( def _get_threads_count() -> int: - return min(os.cpu_count() * SCAN_BATCH_SCANS_PER_CPU, SCAN_BATCH_MAX_PARALLEL_SCANS) + cpu_count = os.cpu_count() or 1 + return min(cpu_count * SCAN_BATCH_SCANS_PER_CPU, SCAN_BATCH_MAX_PARALLEL_SCANS) def run_parallel_batched_scan( diff --git a/cycode/cli/utils/shell_executor.py b/cycode/cli/utils/shell_executor.py index 6b992738..a4ed889a 100644 --- a/cycode/cli/utils/shell_executor.py +++ b/cycode/cli/utils/shell_executor.py @@ -9,7 +9,7 @@ def shell( - command: Union[str, List[str]], timeout: int = _SUBPROCESS_DEFAULT_TIMEOUT_SEC, execute_in_shell=False + command: Union[str, List[str]], timeout: int = _SUBPROCESS_DEFAULT_TIMEOUT_SEC, execute_in_shell: bool = False ) -> Optional[str]: logger.debug(f'Executing shell command: {command}') diff --git a/cycode/cli/utils/string_utils.py b/cycode/cli/utils/string_utils.py index 790f65b2..9dce0026 100644 --- a/cycode/cli/utils/string_utils.py +++ b/cycode/cli/utils/string_utils.py @@ -30,19 +30,19 @@ def is_binary_content(content: str) -> bool: return is_binary_string(chunk_bytes) -def get_content_size(content: str): +def get_content_size(content: str) -> int: return getsizeof(content) -def convert_string_to_bytes(content: str): +def convert_string_to_bytes(content: str) -> bytes: return bytes(content, 'UTF-8') -def hash_string_to_sha256(content: str): +def hash_string_to_sha256(content: str) -> str: return hashlib.sha256(content.encode()).hexdigest() -def generate_random_string(string_len: int): +def generate_random_string(string_len: int) -> str: # letters, digits, and symbols characters = string.ascii_letters + string.digits + string.punctuation return ''.join(random.choice(characters) for _ in range(string_len)) # noqa: S311 diff --git a/cycode/cli/utils/task_timer.py b/cycode/cli/utils/task_timer.py index d6399ba7..0179179d 100644 --- a/cycode/cli/utils/task_timer.py +++ b/cycode/cli/utils/task_timer.py @@ -5,7 +5,7 @@ class FunctionContext: - def __init__(self, function: Callable, args: Optional[List] = None, kwargs: Optional[Dict] = None): + def __init__(self, function: Callable, args: Optional[List] = None, kwargs: Optional[Dict] = None) -> None: self.function = function self.args = args or [] self.kwargs = kwargs or {} @@ -20,25 +20,25 @@ class TimerThread(Thread): quit_function (Mandatory) - function to perform when reaching to timeout """ - def __init__(self, timeout: int, quit_function: FunctionContext): + def __init__(self, timeout: int, quit_function: FunctionContext) -> None: Thread.__init__(self) self._timeout = timeout self._quit_function = quit_function self.event = Event() - def run(self): + def run(self) -> None: self._run_quit_function_on_timeout() - def stop(self): + def stop(self) -> None: self.event.set() - def _run_quit_function_on_timeout(self): + def _run_quit_function_on_timeout(self) -> None: self.event.wait(self._timeout) if not self.event.is_set(): self._call_quit_function() self.stop() - def _call_quit_function(self): + def _call_quit_function(self) -> None: self._quit_function.function(*self._quit_function.args, **self._quit_function.kwargs) @@ -56,7 +56,7 @@ class TimeoutAfter: the default option is to interrupt main thread """ - def __init__(self, timeout: int, quit_function: Optional[FunctionContext] = None): + def __init__(self, timeout: int, quit_function: Optional[FunctionContext] = None) -> None: self.timeout = timeout self._quit_function = quit_function or FunctionContext(function=self.timeout_function) self.timer = TimerThread(timeout, quit_function=self._quit_function) @@ -76,5 +76,5 @@ def __exit__( if exc_type == KeyboardInterrupt: raise TimeoutError(f'Task timed out after {self.timeout} seconds') - def timeout_function(self): + def timeout_function(self) -> None: interrupt_main() diff --git a/cycode/cli/utils/yaml_utils.py b/cycode/cli/utils/yaml_utils.py index b9e9f408..3f910537 100644 --- a/cycode/cli/utils/yaml_utils.py +++ b/cycode/cli/utils/yaml_utils.py @@ -1,14 +1,14 @@ -from typing import Dict +from typing import Any, Dict, Hashable import yaml -def read_file(filename: str) -> Dict: +def read_file(filename: str) -> Dict[Hashable, Any]: with open(filename, 'r', encoding='UTF-8') as file: return yaml.safe_load(file) -def update_file(filename: str, content: Dict): +def update_file(filename: str, content: Dict[Hashable, Any]) -> None: try: with open(filename, 'r', encoding='UTF-8') as file: file_content = yaml.safe_load(file) @@ -20,7 +20,7 @@ def update_file(filename: str, content: Dict): yaml.safe_dump(file_content, file) -def _deep_update(source, overrides): +def _deep_update(source: Dict[Hashable, Any], overrides: Dict[Hashable, Any]) -> Dict[Hashable, Any]: for key, value in overrides.items(): if isinstance(value, dict) and value: source[key] = _deep_update(source.get(key, {}), value) diff --git a/cycode/cli/zip_file.py b/cycode/cli/zip_file.py index d4c5c3fc..7d659c8e 100644 --- a/cycode/cli/zip_file.py +++ b/cycode/cli/zip_file.py @@ -1,22 +1,23 @@ import os.path from io import BytesIO +from typing import Optional from zipfile import ZIP_DEFLATED, ZipFile class InMemoryZip(object): - def __init__(self): + def __init__(self) -> None: # Create the in-memory file-like object self.in_memory_zip = BytesIO() self.zip = ZipFile(self.in_memory_zip, 'a', ZIP_DEFLATED, False) - def append(self, filename, unique_id, content): + def append(self, filename: str, unique_id: Optional[str], content: str) -> None: # Write the file to the in-memory zip if unique_id: filename = concat_unique_id(filename, unique_id) self.zip.writestr(filename, content) - def close(self): + def close(self) -> None: self.zip.close() # to bytes @@ -27,7 +28,7 @@ def read(self) -> bytes: def concat_unique_id(filename: str, unique_id: str) -> str: if filename.startswith(os.sep): - # remove leading slash to join path correctly + # remove leading slash to join the path correctly filename = filename[len(os.sep) :] return os.path.join(unique_id, filename) diff --git a/cycode/cyclient/auth_client.py b/cycode/cyclient/auth_client.py index 5717c583..9cd35f34 100644 --- a/cycode/cyclient/auth_client.py +++ b/cycode/cyclient/auth_client.py @@ -11,7 +11,7 @@ class AuthClient: AUTH_CONTROLLER_PATH = 'api/v1/device-auth' - def __init__(self): + def __init__(self) -> None: self.cycode_client = CycodeClient() def start_session(self, code_challenge: str) -> models.AuthenticationSession: diff --git a/cycode/cyclient/config.py b/cycode/cyclient/config.py index c6ed6432..2c2f3c00 100644 --- a/cycode/cyclient/config.py +++ b/cycode/cyclient/config.py @@ -1,6 +1,7 @@ import logging import os import sys +from typing import Optional from urllib.parse import urlparse from cycode.cli import consts @@ -39,7 +40,7 @@ configuration = dict(DEFAULT_CONFIGURATION, **os.environ) -def get_logger(logger_name=None): +def get_logger(logger_name: Optional[str] = None) -> logging.Logger: logger = logging.getLogger(logger_name) level = _get_val_as_string(consts.LOGGING_LEVEL_ENV_VAR_NAME) level = level if level in logging._nameToLevel else int(level) @@ -48,18 +49,21 @@ def get_logger(logger_name=None): return logger -def _get_val_as_string(key): +def _get_val_as_string(key: str) -> str: return configuration.get(key) -def _get_val_as_bool(key, default=''): +def _get_val_as_bool(key: str, default: str = '') -> bool: val = configuration.get(key, default) return val.lower() in ('true', '1') -def _get_val_as_int(key): +def _get_val_as_int(key: str) -> Optional[int]: val = configuration.get(key) - return int(val) if val is not None else None + if val: + return int(val) + + return None logger = get_logger('cycode cli') diff --git a/cycode/cyclient/cycode_client.py b/cycode/cyclient/cycode_client.py index ed3781f7..dfbd2269 100644 --- a/cycode/cyclient/cycode_client.py +++ b/cycode/cyclient/cycode_client.py @@ -3,6 +3,6 @@ class CycodeClient(CycodeClientBase): - def __init__(self): + def __init__(self) -> None: super().__init__(config.cycode_api_url) self.timeout = config.timeout diff --git a/cycode/cyclient/cycode_client_base.py b/cycode/cyclient/cycode_client_base.py index d4c27c0e..031d184b 100644 --- a/cycode/cyclient/cycode_client_base.py +++ b/cycode/cyclient/cycode_client_base.py @@ -31,7 +31,7 @@ def get_cli_user_agent() -> str: class CycodeClientBase: MANDATORY_HEADERS: ClassVar[Dict[str, str]] = {'User-Agent': get_cli_user_agent()} - def __init__(self, api_url: str): + def __init__(self, api_url: str) -> None: self.timeout = config.timeout self.api_url = api_url @@ -69,7 +69,7 @@ def _execute( except Exception as e: self._handle_exception(e) - def get_request_headers(self, additional_headers: Optional[dict] = None, **kwargs) -> dict: + def get_request_headers(self, additional_headers: Optional[dict] = None, **kwargs) -> Dict[str, str]: if additional_headers is None: return self.MANDATORY_HEADERS.copy() return {**self.MANDATORY_HEADERS, **additional_headers} @@ -77,7 +77,7 @@ def get_request_headers(self, additional_headers: Optional[dict] = None, **kwarg def build_full_url(self, url: str, endpoint: str) -> str: return f'{url}/{endpoint}' - def _handle_exception(self, e: Exception): + def _handle_exception(self, e: Exception) -> None: if isinstance(e, exceptions.Timeout): raise NetworkError(504, 'Timeout Error', e.response) @@ -89,7 +89,7 @@ def _handle_exception(self, e: Exception): raise e @staticmethod - def _handle_http_exception(e: exceptions.HTTPError): + def _handle_http_exception(e: exceptions.HTTPError) -> None: if e.response.status_code == 401: raise HttpUnauthorizedError(e.response.text, e.response) diff --git a/cycode/cyclient/cycode_dev_based_client.py b/cycode/cyclient/cycode_dev_based_client.py index bb647acf..f325bd6e 100644 --- a/cycode/cyclient/cycode_dev_based_client.py +++ b/cycode/cyclient/cycode_dev_based_client.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Dict, Optional from .config import dev_tenant_id from .cycode_client_base import CycodeClientBase @@ -9,14 +9,14 @@ class CycodeDevBasedClient(CycodeClientBase): - def __init__(self, api_url): + def __init__(self, api_url: str) -> None: super().__init__(api_url) - def get_request_headers(self, additional_headers: Optional[dict] = None): + def get_request_headers(self, additional_headers: Optional[dict] = None, **_) -> Dict[str, str]: headers = super().get_request_headers(additional_headers=additional_headers) headers['X-Tenant-Id'] = dev_tenant_id return headers - def build_full_url(self, url, endpoint): + def build_full_url(self, url: str, endpoint: str) -> str: return f'{url}:{endpoint}' diff --git a/cycode/cyclient/cycode_token_based_client.py b/cycode/cyclient/cycode_token_based_client.py index b8898a41..59287c80 100644 --- a/cycode/cyclient/cycode_token_based_client.py +++ b/cycode/cyclient/cycode_token_based_client.py @@ -9,7 +9,7 @@ class CycodeTokenBasedClient(CycodeClient): """Send requests with api token""" - def __init__(self, client_id: str, client_secret: str): + def __init__(self, client_id: str, client_secret: str) -> None: super().__init__() self.client_secret = client_secret self.client_id = client_id @@ -41,7 +41,7 @@ def refresh_api_token(self) -> None: self._api_token = auth_response_data['token'] self._expires_in = arrow.utcnow().shift(seconds=auth_response_data['expires_in'] * 0.8) - def get_request_headers(self, additional_headers: Optional[dict] = None, without_auth=False) -> dict: + def get_request_headers(self, additional_headers: Optional[dict] = None, without_auth: bool = False) -> dict: headers = super().get_request_headers(additional_headers=additional_headers) if not without_auth: diff --git a/cycode/cyclient/models.py b/cycode/cyclient/models.py index 89e39b11..f5983083 100644 --- a/cycode/cyclient/models.py +++ b/cycode/cyclient/models.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional from marshmallow import EXCLUDE, Schema, fields, post_load @@ -13,7 +13,7 @@ def __init__( detection_details: dict, detection_rule_id: str, severity: Optional[str] = None, - ): + ) -> None: super().__init__() self.message = message self.type = type @@ -45,12 +45,12 @@ class Meta: detection_rule_id = fields.String() @post_load - def build_dto(self, data, **kwargs): + def build_dto(self, data: Dict[str, Any], **_) -> Detection: return Detection(**data) class DetectionsPerFile(Schema): - def __init__(self, file_name: str, detections: List[Detection], commit_id: Optional[str] = None): + def __init__(self, file_name: str, detections: List[Detection], commit_id: Optional[str] = None) -> None: super().__init__() self.file_name = file_name self.detections = detections @@ -66,7 +66,7 @@ class Meta: commit_id = fields.String(allow_none=True) @post_load - def build_dto(self, data, **kwargs): + def build_dto(self, data: Dict[str, Any], **_) -> 'DetectionsPerFile': return DetectionsPerFile(**data) @@ -78,7 +78,7 @@ def __init__( report_url: Optional[str] = None, scan_id: Optional[str] = None, err: Optional[str] = None, - ): + ) -> None: super().__init__() self.did_detect = did_detect self.detections_per_file = detections_per_file @@ -98,7 +98,7 @@ class Meta: err = fields.String() @post_load - def build_dto(self, data, **kwargs): + def build_dto(self, data: Dict[str, Any], **_) -> 'ZippedFileScanResult': return ZippedFileScanResult(**data) @@ -109,7 +109,7 @@ def __init__( scan_id: Optional[str] = None, detections: Optional[List[Detection]] = None, err: Optional[str] = None, - ): + ) -> None: super().__init__() self.did_detect = did_detect self.scan_id = scan_id @@ -127,12 +127,12 @@ class Meta: err = fields.String() @post_load - def build_dto(self, data, **kwargs): + def build_dto(self, data: Dict[str, Any], **_) -> 'ScanResult': return ScanResult(**data) class ScanInitializationResponse(Schema): - def __init__(self, scan_id: Optional[str] = None, err: Optional[str] = None): + def __init__(self, scan_id: Optional[str] = None, err: Optional[str] = None) -> None: super().__init__() self.scan_id = scan_id self.err = err @@ -146,7 +146,7 @@ class Meta: err = fields.String() @post_load - def build_dto(self, data, **kwargs): + def build_dto(self, data: Dict[str, Any], **_) -> 'ScanInitializationResponse': return ScanInitializationResponse(**data) @@ -160,7 +160,7 @@ def __init__( message: Optional[str] = None, scan_update_at: Optional[str] = None, err: Optional[str] = None, - ): + ) -> None: super().__init__() self.id = id self.scan_status = scan_status @@ -184,12 +184,12 @@ class Meta: err = fields.String() @post_load - def build_dto(self, data, **kwargs): + def build_dto(self, data: Dict[str, Any], **_) -> 'ScanDetailsResponse': return ScanDetailsResponse(**data) class K8SResource: - def __init__(self, name: str, resource_type: str, namespace: str, content: Dict): + def __init__(self, name: str, resource_type: str, namespace: str, content: Dict) -> None: super().__init__() self.name = name self.type = resource_type @@ -198,23 +198,23 @@ def __init__(self, name: str, resource_type: str, namespace: str, content: Dict) self.internal_metadata = None self.schema = K8SResourceSchema() - def to_json(self): + def to_json(self) -> dict: # FIXME(MarshalX): rename to to_dict? return self.schema.dump(self) class InternalMetadata: - def __init__(self, root_entity_name: str, root_entity_type: str): + def __init__(self, root_entity_name: str, root_entity_type: str) -> None: super().__init__() self.root_entity_name = root_entity_name self.root_entity_type = root_entity_type self.schema = InternalMetadataSchema() - def to_json(self): + def to_json(self) -> dict: # FIXME(MarshalX): rename to to_dict? return self.schema.dump(self) class ResourcesCollection: - def __init__(self, resource_type: str, namespace: str, resources: List[K8SResource], total_count: int): + def __init__(self, resource_type: str, namespace: str, resources: List[K8SResource], total_count: int) -> None: super().__init__() self.type = resource_type self.namespace = namespace @@ -222,7 +222,7 @@ def __init__(self, resource_type: str, namespace: str, resources: List[K8SResour self.total_count = total_count self.schema = ResourcesCollectionSchema() - def to_json(self): + def to_json(self) -> dict: # FIXME(MarshalX): rename to to_dict? return self.schema.dump(self) @@ -247,17 +247,17 @@ class ResourcesCollectionSchema(Schema): class OwnerReference: - def __init__(self, name: str, kind: str): + def __init__(self, name: str, kind: str) -> None: super().__init__() self.name = name self.kind = kind - def __str__(self): + def __str__(self) -> str: return 'Name: {0}, Kind: {1}'.format(self.name, self.kind) class AuthenticationSession(Schema): - def __init__(self, session_id: str): + def __init__(self, session_id: str) -> None: super().__init__() self.session_id = session_id @@ -269,12 +269,12 @@ class Meta: session_id = fields.String() @post_load - def build_dto(self, data, **kwargs): + def build_dto(self, data: Dict[str, Any], **_) -> 'AuthenticationSession': return AuthenticationSession(**data) class ApiToken(Schema): - def __init__(self, client_id: str, secret: str, description: str): + def __init__(self, client_id: str, secret: str, description: str) -> None: super().__init__() self.client_id = client_id self.secret = secret @@ -290,12 +290,12 @@ class Meta: description = fields.String() @post_load - def build_dto(self, data, **kwargs): + def build_dto(self, data: Dict[str, Any], **_) -> 'ApiToken': return ApiToken(**data) class ApiTokenGenerationPollingResponse(Schema): - def __init__(self, status: str, api_token): + def __init__(self, status: str, api_token: 'ApiToken') -> None: super().__init__() self.status = status self.api_token = api_token @@ -309,7 +309,7 @@ class Meta: api_token = fields.Nested(ApiTokenSchema, allow_none=True) @post_load - def build_dto(self, data, **kwargs): + def build_dto(self, data: Dict[str, Any], **_) -> 'ApiTokenGenerationPollingResponse': return ApiTokenGenerationPollingResponse(**data) @@ -320,7 +320,7 @@ class UserAgentOptionScheme(Schema): env_version = fields.String(required=True) # ex. 1.78.2 @post_load - def build_dto(self, data: dict, **_) -> 'UserAgentOption': + def build_dto(self, data: Dict[str, Any], **_) -> 'UserAgentOption': return UserAgentOption(**data) diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index 03844da1..3eb3b310 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -11,7 +11,7 @@ class ScanClient: - def __init__(self, scan_cycode_client: CycodeClientBase, scan_config: ScanConfigBase): + def __init__(self, scan_cycode_client: CycodeClientBase, scan_config: ScanConfigBase) -> None: self.scan_cycode_client = scan_cycode_client self.scan_config = scan_config self.SCAN_CONTROLLER_PATH = 'api/v1/scan' @@ -119,7 +119,7 @@ def commit_range_zipped_file_scan( response = self.scan_cycode_client.post(url_path=url_path, data={'scan_id': scan_id}, files=files) return self.parse_zipped_file_scan_response(response) - def report_scan_status(self, scan_type: str, scan_id: str, scan_status: dict): + def report_scan_status(self, scan_type: str, scan_id: str, scan_status: dict) -> None: url_path = f'{self.scan_config.get_service_name(scan_type)}/{self.SCAN_CONTROLLER_PATH}/{scan_id}/status' self.scan_cycode_client.post(url_path=url_path, body=scan_status) diff --git a/cycode/cyclient/scan_config/scan_config_base.py b/cycode/cyclient/scan_config/scan_config_base.py index b4e0cb53..976008bb 100644 --- a/cycode/cyclient/scan_config/scan_config_base.py +++ b/cycode/cyclient/scan_config/scan_config_base.py @@ -1,51 +1,49 @@ from abc import ABC, abstractmethod -from typing import Optional class ScanConfigBase(ABC): @abstractmethod - def get_service_name(self, scan_type): - pass + def get_service_name(self, scan_type: str) -> str: + ... @abstractmethod - def get_scans_prefix(self): - pass + def get_scans_prefix(self) -> str: + ... @abstractmethod - def get_detections_prefix(self): - pass + def get_detections_prefix(self) -> str: + ... class DevScanConfig(ScanConfigBase): - def get_service_name(self, scan_type): + def get_service_name(self, scan_type: str) -> str: if scan_type == 'secret': return '5025' if scan_type == 'iac': return '5026' - if scan_type == 'sca' or scan_type == 'sast': - return '5004' - return None - def get_scans_prefix(self): + # sca and sast return '5004' - def get_detections_prefix(self): + def get_scans_prefix(self) -> str: + return '5004' + + def get_detections_prefix(self) -> str: return '5016' class DefaultScanConfig(ScanConfigBase): - def get_service_name(self, scan_type) -> Optional[str]: + def get_service_name(self, scan_type: str) -> str: if scan_type == 'secret': return 'secret' if scan_type == 'iac': return 'iac' - if scan_type == 'sca' or scan_type == 'sast': - return 'scans' - return None + # sca and sast + return 'scans' - def get_scans_prefix(self): + def get_scans_prefix(self) -> str: return 'scans' - def get_detections_prefix(self): + def get_detections_prefix(self) -> str: return 'detections' diff --git a/cycode/cyclient/utils.py b/cycode/cyclient/utils.py deleted file mode 100644 index 4b6c1d1c..00000000 --- a/cycode/cyclient/utils.py +++ /dev/null @@ -1,10 +0,0 @@ -import os - - -def split_list(input_list, batch_size): - for i in range(0, len(input_list), batch_size): - yield input_list[i : i + batch_size] - - -def cpu_count(): - return os.cpu_count() or 1 diff --git a/pyproject.toml b/pyproject.toml index dd3c8e04..292e53d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,7 +96,7 @@ extend-select = [ "Q", # flake8-quotes "S", # flake8-bandit "ASYNC", # flake8-async - # "ANN", # flake8-annotations. Enable later + "ANN", # flake8-annotations "C", "BLE", "ERA", @@ -111,12 +111,18 @@ extend-select = [ "RUF", "SIM", "T20", - # "TCH", # it can't be autofixed yet + "TCH", "TID", "YTT", ] line-length = 120 target-version = "py37" +ignore = [ + "ANN002", # Missing type annotation for `*args` + "ANN003", # Missing type annotation for `**kwargs` + "ANN101", # Missing type annotation for `self` in method + "ANN102", # Missing type annotation for `cls` in classmethod +] [tool.ruff.flake8-quotes] docstring-quotes = "double" diff --git a/tests/__init__.py b/tests/__init__.py index 80fbb2d8..617049bb 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -28,7 +28,3 @@ } K8S_POD_MOCK = K8SResource('pod-template-123xyz', 'pod', 'default', POD_MOCK) - - -def list_to_str(values): - return ','.join([str(val) for val in values]) diff --git a/tests/cli/test_code_scanner.py b/tests/cli/test_code_scanner.py index 041dac49..0bd02845 100644 --- a/tests/cli/test_code_scanner.py +++ b/tests/cli/test_code_scanner.py @@ -1,4 +1,5 @@ import os +from typing import TYPE_CHECKING import click import pytest @@ -9,9 +10,12 @@ from cycode.cli.code_scanner import _handle_exception, _is_file_relevant_for_sca_scan from cycode.cli.exceptions import custom_exceptions +if TYPE_CHECKING: + from _pytest.monkeypatch import MonkeyPatch + @pytest.fixture() -def ctx(): +def ctx() -> click.Context: return click.Context(click.Command('path'), obj={'verbose': False, 'output': 'text'}) @@ -27,7 +31,7 @@ def ctx(): ) def test_handle_exception_soft_fail( ctx: click.Context, exception: custom_exceptions.CycodeError, expected_soft_fail: bool -): +) -> None: with ctx: _handle_exception(ctx, exception) @@ -35,7 +39,7 @@ def test_handle_exception_soft_fail( assert ctx.obj.get('soft_fail') is expected_soft_fail -def test_handle_exception_unhandled_error(ctx: click.Context): +def test_handle_exception_unhandled_error(ctx: click.Context) -> None: with ctx, pytest.raises(ClickException): _handle_exception(ctx, ValueError('test')) @@ -43,7 +47,7 @@ def test_handle_exception_unhandled_error(ctx: click.Context): assert ctx.obj.get('soft_fail') is None -def test_handle_exception_click_error(ctx: click.Context): +def test_handle_exception_click_error(ctx: click.Context) -> None: with ctx, pytest.raises(ClickException): _handle_exception(ctx, click.ClickException('test')) @@ -51,10 +55,10 @@ def test_handle_exception_click_error(ctx: click.Context): assert ctx.obj.get('soft_fail') is None -def test_handle_exception_verbose(monkeypatch): +def test_handle_exception_verbose(monkeypatch: 'MonkeyPatch') -> None: ctx = click.Context(click.Command('path'), obj={'verbose': True, 'output': 'text'}) - def mock_secho(msg, *_, **__): + def mock_secho(msg: str, *_, **__) -> None: assert 'Error:' in msg monkeypatch.setattr(click, 'secho', mock_secho) @@ -63,7 +67,7 @@ def mock_secho(msg, *_, **__): _handle_exception(ctx, ValueError('test')) -def test_is_file_relevant_for_sca_scan(): +def test_is_file_relevant_for_sca_scan() -> None: path = os.path.join('some_package', 'node_modules', 'package.json') assert _is_file_relevant_for_sca_scan(path) is False path = os.path.join('some_package', 'node_modules', 'package.lock') diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py index 2996c0d4..77375920 100644 --- a/tests/cli/test_main.py +++ b/tests/cli/test_main.py @@ -28,7 +28,7 @@ def _is_json(plain: str) -> bool: @pytest.mark.parametrize('option_space', ['scan', 'global']) def test_passing_output_option( output: str, option_space: str, scan_client: 'ScanClient', api_token_response: responses.Response -): +) -> None: scan_type = 'secret' responses.add(get_zipped_file_scan_response(get_zipped_file_scan_url(scan_type, scan_client))) diff --git a/tests/conftest.py b/tests/conftest.py index 72860e70..ae7d4cd4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,7 +33,7 @@ def api_token_url(token_based_client: CycodeTokenBasedClient) -> str: @pytest.fixture(scope='session') -def api_token_response(api_token_url) -> responses.Response: +def api_token_response(api_token_url: str) -> responses.Response: return responses.Response( method=responses.POST, url=api_token_url, diff --git a/tests/cyclient/scan_config/test_default_scan_config.py b/tests/cyclient/scan_config/test_default_scan_config.py index 1043f505..0945402b 100644 --- a/tests/cyclient/scan_config/test_default_scan_config.py +++ b/tests/cyclient/scan_config/test_default_scan_config.py @@ -1,7 +1,7 @@ from cycode.cyclient.scan_config.scan_config_creator import DefaultScanConfig -def test_get_service_name(): +def test_get_service_name() -> None: default_scan_config = DefaultScanConfig() assert default_scan_config.get_service_name('secret') == 'secret' @@ -10,13 +10,13 @@ def test_get_service_name(): assert default_scan_config.get_service_name('sast') == 'scans' -def test_get_scans_prefix(): +def test_get_scans_prefix() -> None: default_scan_config = DefaultScanConfig() assert default_scan_config.get_scans_prefix() == 'scans' -def test_get_detections_prefix(): +def test_get_detections_prefix() -> None: default_scan_config = DefaultScanConfig() assert default_scan_config.get_detections_prefix() == 'detections' diff --git a/tests/cyclient/scan_config/test_dev_scan_config.py b/tests/cyclient/scan_config/test_dev_scan_config.py index 28f42c63..0673d601 100644 --- a/tests/cyclient/scan_config/test_dev_scan_config.py +++ b/tests/cyclient/scan_config/test_dev_scan_config.py @@ -1,7 +1,7 @@ from cycode.cyclient.scan_config.scan_config_creator import DevScanConfig -def test_get_service_name(): +def test_get_service_name() -> None: dev_scan_config = DevScanConfig() assert dev_scan_config.get_service_name('secret') == '5025' @@ -10,13 +10,13 @@ def test_get_service_name(): assert dev_scan_config.get_service_name('sast') == '5004' -def test_get_scans_prefix(): +def test_get_scans_prefix() -> None: dev_scan_config = DevScanConfig() assert dev_scan_config.get_scans_prefix() == '5004' -def test_get_detections_prefix(): +def test_get_detections_prefix() -> None: dev_scan_config = DevScanConfig() assert dev_scan_config.get_detections_prefix() == '5016' diff --git a/tests/cyclient/test_auth_client.py b/tests/cyclient/test_auth_client.py index b3898fd3..0a34d3b2 100644 --- a/tests/cyclient/test_auth_client.py +++ b/tests/cyclient/test_auth_client.py @@ -48,7 +48,7 @@ def auth_token_url(client: AuthClient) -> str: @responses.activate -def test_start_session_success(client: AuthClient, start_url: str, code_challenge: str): +def test_start_session_success(client: AuthClient, start_url: str, code_challenge: str) -> None: responses.add( responses.POST, start_url, @@ -62,7 +62,7 @@ def test_start_session_success(client: AuthClient, start_url: str, code_challeng @responses.activate -def test_start_session_timeout(client: AuthClient, start_url: str, code_challenge: str): +def test_start_session_timeout(client: AuthClient, start_url: str, code_challenge: str) -> None: responses.add(responses.POST, start_url, status=504) timeout_response = requests.post(start_url, timeout=5) @@ -83,7 +83,7 @@ def test_start_session_timeout(client: AuthClient, start_url: str, code_challeng @responses.activate -def test_start_session_http_error(client: AuthClient, start_url: str, code_challenge: str): +def test_start_session_http_error(client: AuthClient, start_url: str, code_challenge: str) -> None: responses.add(responses.POST, start_url, status=401) with pytest.raises(CycodeError) as e_info: @@ -93,7 +93,7 @@ def test_start_session_http_error(client: AuthClient, start_url: str, code_chall @responses.activate -def test_get_api_token_success_pending(client: AuthClient, token_url: str, code_verifier: str): +def test_get_api_token_success_pending(client: AuthClient, token_url: str, code_verifier: str) -> None: expected_status = 'Pending' expected_api_token = None @@ -111,7 +111,7 @@ def test_get_api_token_success_pending(client: AuthClient, token_url: str, code_ @responses.activate -def test_get_api_token_success_completed(client: AuthClient, token_url: str, code_verifier: str): +def test_get_api_token_success_completed(client: AuthClient, token_url: str, code_verifier: str) -> None: expected_status = 'Completed' expected_json = { 'status': expected_status, @@ -141,7 +141,7 @@ def test_get_api_token_success_completed(client: AuthClient, token_url: str, cod @responses.activate -def test_get_api_token_http_error_valid_response(client: AuthClient, token_url: str, code_verifier: str): +def test_get_api_token_http_error_valid_response(client: AuthClient, token_url: str, code_verifier: str) -> None: expected_status = 'Pending' expected_api_token = None @@ -159,7 +159,7 @@ def test_get_api_token_http_error_valid_response(client: AuthClient, token_url: @responses.activate -def test_get_api_token_http_error_invalid_response(client: AuthClient, token_url: str, code_verifier: str): +def test_get_api_token_http_error_invalid_response(client: AuthClient, token_url: str, code_verifier: str) -> None: responses.add( responses.POST, token_url, @@ -172,7 +172,7 @@ def test_get_api_token_http_error_invalid_response(client: AuthClient, token_url @responses.activate -def test_get_api_token_not_excepted_exception(client: AuthClient, token_url: str, code_verifier: str): +def test_get_api_token_not_excepted_exception(client: AuthClient, token_url: str, code_verifier: str) -> None: responses.add(responses.POST, token_url, body=Timeout()) api_token_polling_response = client.get_api_token(_SESSION_ID, code_verifier) diff --git a/tests/cyclient/test_client.py b/tests/cyclient/test_client.py index 623877a4..9c68cacf 100644 --- a/tests/cyclient/test_client.py +++ b/tests/cyclient/test_client.py @@ -2,7 +2,7 @@ from cycode.cyclient.cycode_client import CycodeClient -def test_init_values_from_config(): +def test_init_values_from_config() -> None: client = CycodeClient() assert client.api_url == config.cycode_api_url diff --git a/tests/cyclient/test_client_base.py b/tests/cyclient/test_client_base.py index 5a7810a8..d0b00563 100644 --- a/tests/cyclient/test_client_base.py +++ b/tests/cyclient/test_client_base.py @@ -2,7 +2,7 @@ from cycode.cyclient.cycode_client_base import CycodeClientBase, get_cli_user_agent -def test_mandatory_headers(): +def test_mandatory_headers() -> None: expected_headers = { 'User-Agent': get_cli_user_agent(), } @@ -12,13 +12,13 @@ def test_mandatory_headers(): assert expected_headers == client.MANDATORY_HEADERS -def test_get_request_headers(): +def test_get_request_headers() -> None: client = CycodeClientBase(config.cycode_api_url) assert client.get_request_headers() == client.MANDATORY_HEADERS -def test_get_request_headers_with_additional(): +def test_get_request_headers_with_additional() -> None: client = CycodeClientBase(config.cycode_api_url) additional_headers = {'Authorize': 'Token test'} @@ -27,7 +27,7 @@ def test_get_request_headers_with_additional(): assert client.get_request_headers(additional_headers) == expected_headers -def test_build_full_url(): +def test_build_full_url() -> None: url = config.cycode_api_url client = CycodeClientBase(url) diff --git a/tests/cyclient/test_dev_based_client.py b/tests/cyclient/test_dev_based_client.py index e5fcfecd..f270761f 100644 --- a/tests/cyclient/test_dev_based_client.py +++ b/tests/cyclient/test_dev_based_client.py @@ -2,7 +2,7 @@ from cycode.cyclient.cycode_dev_based_client import CycodeDevBasedClient -def test_get_request_headers(): +def test_get_request_headers() -> None: client = CycodeDevBasedClient(config.cycode_api_url) dev_based_headers = {'X-Tenant-Id': config.dev_tenant_id} @@ -11,7 +11,7 @@ def test_get_request_headers(): assert client.get_request_headers() == expected_headers -def test_build_full_url(): +def test_build_full_url() -> None: url = config.cycode_api_url client = CycodeDevBasedClient(url) diff --git a/tests/cyclient/test_scan_client.py b/tests/cyclient/test_scan_client.py index fecbaed2..db867a9f 100644 --- a/tests/cyclient/test_scan_client.py +++ b/tests/cyclient/test_scan_client.py @@ -1,5 +1,5 @@ import os -from typing import List, Optional +from typing import List, Optional, Tuple from uuid import UUID, uuid4 import pytest @@ -19,7 +19,7 @@ _ZIP_CONTENT_PATH = TEST_FILES_PATH.joinpath('zip_content').absolute() -def zip_scan_resources(scan_type: str, scan_client: ScanClient): +def zip_scan_resources(scan_type: str, scan_client: ScanClient) -> Tuple[str, InMemoryZip]: url = get_zipped_file_scan_url(scan_type, scan_client) zip_file = get_test_zip_file(scan_type) @@ -84,7 +84,7 @@ def get_zipped_file_scan_response(url: str, scan_id: Optional[UUID] = None) -> r return responses.Response(method=responses.POST, url=url, json=json_response, status=200) -def test_get_service_name(scan_client: ScanClient): +def test_get_service_name(scan_client: ScanClient) -> None: # TODO(MarshalX): get_service_name should be removed from ScanClient? Because it exists in ScanConfig assert scan_client.get_service_name('secret') == 'secret' assert scan_client.get_service_name('iac') == 'iac' @@ -94,7 +94,7 @@ def test_get_service_name(scan_client: ScanClient): @pytest.mark.parametrize('scan_type', config['scans']['supported_scans']) @responses.activate -def test_zipped_file_scan(scan_type: str, scan_client: ScanClient, api_token_response): +def test_zipped_file_scan(scan_type: str, scan_client: ScanClient, api_token_response: responses.Response) -> None: url, zip_file = zip_scan_resources(scan_type, scan_client) expected_scan_id = uuid4() @@ -109,7 +109,9 @@ def test_zipped_file_scan(scan_type: str, scan_client: ScanClient, api_token_res @pytest.mark.parametrize('scan_type', config['scans']['supported_scans']) @responses.activate -def test_zipped_file_scan_unauthorized_error(scan_type: str, scan_client: ScanClient, api_token_response): +def test_zipped_file_scan_unauthorized_error( + scan_type: str, scan_client: ScanClient, api_token_response: responses.Response +) -> None: url, zip_file = zip_scan_resources(scan_type, scan_client) expected_scan_id = uuid4().hex @@ -124,7 +126,9 @@ def test_zipped_file_scan_unauthorized_error(scan_type: str, scan_client: ScanCl @pytest.mark.parametrize('scan_type', config['scans']['supported_scans']) @responses.activate -def test_zipped_file_scan_bad_request_error(scan_type: str, scan_client: ScanClient, api_token_response): +def test_zipped_file_scan_bad_request_error( + scan_type: str, scan_client: ScanClient, api_token_response: responses.Response +) -> None: url, zip_file = zip_scan_resources(scan_type, scan_client) expected_scan_id = uuid4().hex @@ -143,7 +147,9 @@ def test_zipped_file_scan_bad_request_error(scan_type: str, scan_client: ScanCli @pytest.mark.parametrize('scan_type', config['scans']['supported_scans']) @responses.activate -def test_zipped_file_scan_timeout_error(scan_type: str, scan_client: ScanClient, api_token_response): +def test_zipped_file_scan_timeout_error( + scan_type: str, scan_client: ScanClient, api_token_response: responses.Response +) -> None: scan_url, zip_file = zip_scan_resources(scan_type, scan_client) expected_scan_id = uuid4().hex @@ -170,7 +176,9 @@ def test_zipped_file_scan_timeout_error(scan_type: str, scan_client: ScanClient, @pytest.mark.parametrize('scan_type', config['scans']['supported_scans']) @responses.activate -def test_zipped_file_scan_connection_error(scan_type: str, scan_client: ScanClient, api_token_response): +def test_zipped_file_scan_connection_error( + scan_type: str, scan_client: ScanClient, api_token_response: responses.Response +) -> None: url, zip_file = zip_scan_resources(scan_type, scan_client) expected_scan_id = uuid4().hex diff --git a/tests/cyclient/test_token_based_client.py b/tests/cyclient/test_token_based_client.py index ebc672fb..b5d824f4 100644 --- a/tests/cyclient/test_token_based_client.py +++ b/tests/cyclient/test_token_based_client.py @@ -6,7 +6,7 @@ @responses.activate -def test_api_token_new(token_based_client: CycodeTokenBasedClient, api_token_response: responses.Response): +def test_api_token_new(token_based_client: CycodeTokenBasedClient, api_token_response: responses.Response) -> None: responses.add(api_token_response) api_token = token_based_client.api_token @@ -15,7 +15,7 @@ def test_api_token_new(token_based_client: CycodeTokenBasedClient, api_token_res @responses.activate -def test_api_token_expired(token_based_client: CycodeTokenBasedClient, api_token_response: responses.Response): +def test_api_token_expired(token_based_client: CycodeTokenBasedClient, api_token_response: responses.Response) -> None: responses.add(api_token_response) # this property performs HTTP req to refresh the token. IDE doesn't know it @@ -30,7 +30,7 @@ def test_api_token_expired(token_based_client: CycodeTokenBasedClient, api_token assert api_token_refreshed == _EXPECTED_API_TOKEN -def test_get_request_headers(token_based_client: CycodeTokenBasedClient, api_token: str): +def test_get_request_headers(token_based_client: CycodeTokenBasedClient, api_token: str) -> None: token_based_headers = {'Authorization': f'Bearer {_EXPECTED_API_TOKEN}'} expected_headers = {**token_based_client.MANDATORY_HEADERS, **token_based_headers} diff --git a/tests/test_code_scanner.py b/tests/test_code_scanner.py index 9e17a0c5..b1f7e163 100644 --- a/tests/test_code_scanner.py +++ b/tests/test_code_scanner.py @@ -4,6 +4,6 @@ from tests.conftest import TEST_FILES_PATH -def test_is_relevant_file_to_scan_sca(): +def test_is_relevant_file_to_scan_sca() -> None: path = os.path.join(TEST_FILES_PATH, 'package.json') assert code_scanner._is_relevant_file_to_scan('sca', path) is True diff --git a/tests/test_models.py b/tests/test_models.py index bb583b8e..38998250 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -2,7 +2,7 @@ from tests import PODS_MOCK -def test_batch_resources_to_json(): +def test_batch_resources_to_json() -> None: batch = ResourcesCollection('pod', 'default', PODS_MOCK, 77777) json_dict = batch.to_json() assert 'resources' in json_dict @@ -15,7 +15,7 @@ def test_batch_resources_to_json(): assert json_dict['resources'][0]['name'] == 'pod_name_1' -def test_internal_metadata_to_json(): +def test_internal_metadata_to_json() -> None: resource = K8SResource('nginx-template-123-456', 'pod', 'cycode', {}) resource.internal_metadata = InternalMetadata('nginx-template', 'deployment') batch = ResourcesCollection('pod', 'cycode', [resource], 1) diff --git a/tests/test_zip_file.py b/tests/test_zip_file.py index c05089f5..f73514c8 100644 --- a/tests/test_zip_file.py +++ b/tests/test_zip_file.py @@ -3,7 +3,7 @@ from cycode.cli import zip_file -def test_concat_unique_id_to_file_with_leading_slash(): +def test_concat_unique_id_to_file_with_leading_slash() -> None: filename = os.path.join('path', 'to', 'file') # we should care about slash characters in tests unique_id = 'unique_id' @@ -13,7 +13,7 @@ def test_concat_unique_id_to_file_with_leading_slash(): assert zip_file.concat_unique_id(filename, unique_id) == expected_path -def test_concat_unique_id_to_file_without_leading_slash(): +def test_concat_unique_id_to_file_without_leading_slash() -> None: filename = os.path.join('path', 'to', 'file') # we should care about slash characters in tests unique_id = 'unique_id' diff --git a/tests/user_settings/test_configuration_manager.py b/tests/user_settings/test_configuration_manager.py index 1da4a346..50251340 100644 --- a/tests/user_settings/test_configuration_manager.py +++ b/tests/user_settings/test_configuration_manager.py @@ -1,8 +1,13 @@ +from typing import TYPE_CHECKING, Optional + from mock import Mock from cycode.cli.consts import DEFAULT_CYCODE_API_URL from cycode.cli.user_settings.configuration_manager import ConfigurationManager +if TYPE_CHECKING: + from pytest_mock import MockerFixture + """ we check for base url in the three places, in the following order: 1. environment vars @@ -14,7 +19,7 @@ GLOBAL_CONFIG_BASE_URL_VALUE = 'url_from_global_config_file' -def test_get_base_url_from_environment_variable(mocker): +def test_get_base_url_from_environment_variable(mocker: 'MockerFixture') -> None: # Arrange configuration_manager = _configure_mocks( mocker, ENV_VARS_BASE_URL_VALUE, LOCAL_CONFIG_FILE_BASE_URL_VALUE, GLOBAL_CONFIG_BASE_URL_VALUE @@ -27,7 +32,7 @@ def test_get_base_url_from_environment_variable(mocker): assert result == ENV_VARS_BASE_URL_VALUE -def test_get_base_url_from_local_config(mocker): +def test_get_base_url_from_local_config(mocker: 'MockerFixture') -> None: # Arrange configuration_manager = _configure_mocks( mocker, None, LOCAL_CONFIG_FILE_BASE_URL_VALUE, GLOBAL_CONFIG_BASE_URL_VALUE @@ -40,7 +45,7 @@ def test_get_base_url_from_local_config(mocker): assert result == LOCAL_CONFIG_FILE_BASE_URL_VALUE -def test_get_base_url_from_global_config(mocker): +def test_get_base_url_from_global_config(mocker: 'MockerFixture') -> None: # Arrange configuration_manager = _configure_mocks(mocker, None, None, GLOBAL_CONFIG_BASE_URL_VALUE) @@ -51,7 +56,7 @@ def test_get_base_url_from_global_config(mocker): assert result == GLOBAL_CONFIG_BASE_URL_VALUE -def test_get_base_url_not_configured(mocker): +def test_get_base_url_not_configured(mocker: 'MockerFixture') -> None: # Arrange configuration_manager = _configure_mocks(mocker, None, None, None) @@ -63,8 +68,11 @@ def test_get_base_url_not_configured(mocker): def _configure_mocks( - mocker, expected_env_var_base_url, expected_local_config_file_base_url, expected_global_config_file_base_url -): + mocker: 'MockerFixture', + expected_env_var_base_url: Optional[str], + expected_local_config_file_base_url: Optional[str], + expected_global_config_file_base_url: Optional[str], +) -> ConfigurationManager: mocker.patch.object( ConfigurationManager, 'get_api_url_from_environment_variables', return_value=expected_env_var_base_url ) diff --git a/tests/user_settings/test_user_settings_commands.py b/tests/user_settings/test_user_settings_commands.py index b63c1e5c..6ef0cf2e 100644 --- a/tests/user_settings/test_user_settings_commands.py +++ b/tests/user_settings/test_user_settings_commands.py @@ -1,9 +1,14 @@ +from typing import TYPE_CHECKING + from click.testing import CliRunner from cycode.cli.user_settings.user_settings_commands import set_credentials +if TYPE_CHECKING: + from pytest_mock import MockerFixture + -def test_set_credentials_no_exist_credentials_in_file(mocker): +def test_set_credentials_no_exist_credentials_in_file(mocker: 'MockerFixture') -> None: # Arrange client_id_user_input = 'new client id' client_secret_user_input = 'new client secret' @@ -26,7 +31,7 @@ def test_set_credentials_no_exist_credentials_in_file(mocker): mocked_update_credentials.assert_called_once_with(client_id_user_input, client_secret_user_input) -def test_set_credentials_update_current_credentials_in_file(mocker): +def test_set_credentials_update_current_credentials_in_file(mocker: 'MockerFixture') -> None: # Arrange client_id_user_input = 'new client id' client_secret_user_input = 'new client secret' @@ -35,7 +40,7 @@ def test_set_credentials_update_current_credentials_in_file(mocker): return_value=('client id file', 'client secret file'), ) - # side effect - multiple return values, each item in the list represent return of a call + # side effect - multiple return values, each item in the list represents return of a call mocker.patch('click.prompt', side_effect=[client_id_user_input, client_secret_user_input]) mocked_update_credentials = mocker.patch( 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials_file' @@ -49,7 +54,7 @@ def test_set_credentials_update_current_credentials_in_file(mocker): mocked_update_credentials.assert_called_once_with(client_id_user_input, client_secret_user_input) -def test_set_credentials_update_only_client_id(mocker): +def test_set_credentials_update_only_client_id(mocker: 'MockerFixture') -> None: # Arrange client_id_user_input = 'new client id' current_client_id = 'client secret file' @@ -72,7 +77,7 @@ def test_set_credentials_update_only_client_id(mocker): mocked_update_credentials.assert_called_once_with(client_id_user_input, current_client_id) -def test_set_credentials_update_only_client_secret(mocker): +def test_set_credentials_update_only_client_secret(mocker: 'MockerFixture') -> None: # Arrange client_secret_user_input = 'new client secret' current_client_id = 'client secret file' @@ -95,7 +100,7 @@ def test_set_credentials_update_only_client_secret(mocker): mocked_update_credentials.assert_called_once_with(current_client_id, client_secret_user_input) -def test_set_credentials_should_not_update_file(mocker): +def test_set_credentials_should_not_update_file(mocker: 'MockerFixture') -> None: # Arrange client_id_user_input = '' client_secret_user_input = '' diff --git a/tests/utils/test_path_utils.py b/tests/utils/test_path_utils.py index d5c9e3ae..eaf67f65 100644 --- a/tests/utils/test_path_utils.py +++ b/tests/utils/test_path_utils.py @@ -4,31 +4,31 @@ from tests.conftest import TEST_FILES_PATH -def test_is_sub_path_both_paths_are_same(): +def test_is_sub_path_both_paths_are_same() -> None: path = os.path.join(TEST_FILES_PATH, 'hello') sub_path = os.path.join(TEST_FILES_PATH, 'hello') assert is_sub_path(path, sub_path) is True -def test_is_sub_path_path_is_not_subpath(): +def test_is_sub_path_path_is_not_subpath() -> None: path = os.path.join(TEST_FILES_PATH, 'hello') sub_path = os.path.join(TEST_FILES_PATH, 'hello.txt') assert is_sub_path(path, sub_path) is False -def test_is_sub_path_path_is_subpath(): +def test_is_sub_path_path_is_subpath() -> None: path = os.path.join(TEST_FILES_PATH, 'hello') sub_path = os.path.join(TEST_FILES_PATH, 'hello', 'random.txt') assert is_sub_path(path, sub_path) is True -def test_is_sub_path_path_not_exists(): +def test_is_sub_path_path_not_exists() -> None: path = os.path.join(TEST_FILES_PATH, 'goodbye') sub_path = os.path.join(TEST_FILES_PATH, 'hello', 'random.txt') assert is_sub_path(path, sub_path) is False -def test_is_sub_path_subpath_not_exists(): +def test_is_sub_path_subpath_not_exists() -> None: path = os.path.join(TEST_FILES_PATH, 'hello', 'random.txt') sub_path = os.path.join(TEST_FILES_PATH, 'goodbye') assert is_sub_path(path, sub_path) is False diff --git a/tests/utils/test_string_utils.py b/tests/utils/test_string_utils.py index 2a78b9f6..60d10efa 100644 --- a/tests/utils/test_string_utils.py +++ b/tests/utils/test_string_utils.py @@ -1,7 +1,7 @@ from cycode.cli.utils.string_utils import shortcut_dependency_paths -def test_shortcut_dependency_paths_list_single_dependencies(): +def test_shortcut_dependency_paths_list_single_dependencies() -> None: dependency_paths = 'A, A -> B, A -> B -> C' expected_result = 'A\n\nA -> B\n\nA -> ... -> C' assert shortcut_dependency_paths(dependency_paths) == expected_result From 40d686ff73f4683db97a768c5a4364137a48de09 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Thu, 6 Jul 2023 16:55:16 +0200 Subject: [PATCH 009/257] CM-25061 - Hide sensitive API responses from debug logs (#138) --- cycode/cli/main.py | 6 ++--- cycode/cyclient/auth_client.py | 2 +- cycode/cyclient/cycode_client_base.py | 11 ++++++-- cycode/cyclient/cycode_token_based_client.py | 1 + cycode/cyclient/scan_client.py | 26 ++++++++++++------- .../scan_config/scan_config_creator.py | 4 +-- tests/conftest.py | 2 +- 7 files changed, 33 insertions(+), 19 deletions(-) diff --git a/cycode/cli/main.py b/cycode/cli/main.py index 0086534e..823701d5 100644 --- a/cycode/cli/main.py +++ b/cycode/cli/main.py @@ -149,7 +149,7 @@ def code_scan( if output == 'json': context.obj['no_progress_meter'] = True - context.obj['client'] = get_cycode_client(client_id, secret) + context.obj['client'] = get_cycode_client(client_id, secret, not context.obj['show_secret']) context.obj['severity_threshold'] = severity_threshold context.obj['monitor'] = monitor context.obj['report'] = report @@ -234,7 +234,7 @@ def main_cli( CycodeClientBase.enrich_user_agent(user_agent_option.user_agent_suffix) -def get_cycode_client(client_id: str, client_secret: str) -> 'ScanClient': +def get_cycode_client(client_id: str, client_secret: str, hide_response_log: bool) -> 'ScanClient': if not client_id or not client_secret: client_id, client_secret = _get_configured_credentials() if not client_id: @@ -242,7 +242,7 @@ def get_cycode_client(client_id: str, client_secret: str) -> 'ScanClient': if not client_secret: raise click.ClickException('Cycode client secret is needed.') - return create_scan_client(client_id, client_secret) + return create_scan_client(client_id, client_secret, hide_response_log) def _get_configured_credentials() -> Tuple[str, str]: diff --git a/cycode/cyclient/auth_client.py b/cycode/cyclient/auth_client.py index 9cd35f34..626d4ff9 100644 --- a/cycode/cyclient/auth_client.py +++ b/cycode/cyclient/auth_client.py @@ -24,7 +24,7 @@ def get_api_token(self, session_id: str, code_verifier: str) -> Optional[models. path = f'{self.AUTH_CONTROLLER_PATH}/token' body = {'session_id': session_id, 'code_verifier': code_verifier} try: - response = self.cycode_client.post(url_path=path, body=body) + response = self.cycode_client.post(url_path=path, body=body, hide_response_content_log=True) return self.parse_api_token_polling_response(response) except (NetworkError, HttpUnauthorizedError) as e: return self.parse_api_token_polling_response(e.response) diff --git a/cycode/cyclient/cycode_client_base.py b/cycode/cyclient/cycode_client_base.py index 031d184b..d804b8cb 100644 --- a/cycode/cyclient/cycode_client_base.py +++ b/cycode/cyclient/cycode_client_base.py @@ -53,7 +53,13 @@ def get(self, url_path: str, headers: Optional[dict] = None, **kwargs) -> Respon return self._execute(method='get', endpoint=url_path, headers=headers, **kwargs) def _execute( - self, method: str, endpoint: str, headers: Optional[dict] = None, without_auth: bool = False, **kwargs + self, + method: str, + endpoint: str, + headers: Optional[dict] = None, + without_auth: bool = False, + hide_response_content_log: bool = False, + **kwargs, ) -> Response: url = self.build_full_url(self.api_url, endpoint) logger.debug(f'Executing {method.upper()} request to {url}') @@ -62,7 +68,8 @@ def _execute( headers = self.get_request_headers(headers, without_auth=without_auth) response = request(method=method, url=url, timeout=self.timeout, headers=headers, **kwargs) - logger.debug(f'Response {response.status_code} from {url}. Content: {response.text}') + content = 'HIDDEN' if hide_response_content_log else response.text + logger.debug(f'Response {response.status_code} from {url}. Content: {content}') response.raise_for_status() return response diff --git a/cycode/cyclient/cycode_token_based_client.py b/cycode/cyclient/cycode_token_based_client.py index 59287c80..c73999fb 100644 --- a/cycode/cyclient/cycode_token_based_client.py +++ b/cycode/cyclient/cycode_token_based_client.py @@ -35,6 +35,7 @@ def refresh_api_token(self) -> None: url_path='api/v1/auth/api-token', body={'clientId': self.client_id, 'secret': self.client_secret}, without_auth=True, + hide_response_content_log=True, ) auth_response_data = auth_response.json() diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index 3eb3b310..f09a96ef 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -11,22 +11,23 @@ class ScanClient: - def __init__(self, scan_cycode_client: CycodeClientBase, scan_config: ScanConfigBase) -> None: + def __init__( + self, scan_cycode_client: CycodeClientBase, scan_config: ScanConfigBase, hide_response_log: bool = True + ) -> None: self.scan_cycode_client = scan_cycode_client self.scan_config = scan_config + self.SCAN_CONTROLLER_PATH = 'api/v1/scan' self.DETECTIONS_SERVICE_CONTROLLER_PATH = 'api/v1/detections' + self._hide_response_log = hide_response_log + def content_scan(self, scan_type: str, file_name: str, content: str, is_git_diff: bool = True) -> models.ScanResult: path = f'{self.scan_config.get_service_name(scan_type)}/{self.SCAN_CONTROLLER_PATH}/content' body = {'name': file_name, 'content': content, 'is_git_diff': is_git_diff} - response = self.scan_cycode_client.post(url_path=path, body=body) - return self.parse_scan_response(response) - - def file_scan(self, scan_type: str, path: str) -> models.ScanResult: - url_path = f'{self.scan_config.get_service_name(scan_type)}/{self.SCAN_CONTROLLER_PATH}' - files = {'file': open(path, 'rb')} # noqa: SIM115 requests lib should care about closing - response = self.scan_cycode_client.post(url_path=url_path, files=files) + response = self.scan_cycode_client.post( + url_path=path, body=body, hide_response_content_log=self._hide_response_log + ) return self.parse_scan_response(response) def zipped_file_scan( @@ -39,6 +40,7 @@ def zipped_file_scan( url_path=url_path, data={'scan_id': scan_id, 'is_git_diff': is_git_diff, 'scan_parameters': json.dumps(scan_parameters)}, files=files, + hide_response_content_log=self._hide_response_log, ) return self.parse_zipped_file_scan_response(response) @@ -96,7 +98,9 @@ def get_scan_detections(self, scan_id: str) -> List[dict]: params['page_size'] = page_size params['page_number'] = page_number - response = self.scan_cycode_client.get(url_path=url_path, params=params).json() + response = self.scan_cycode_client.get( + url_path=url_path, params=params, hide_response_content_log=self._hide_response_log + ).json() detections.extend(response) page_number += 1 @@ -116,7 +120,9 @@ def commit_range_zipped_file_scan( f'{self.scan_config.get_service_name(scan_type)}/{self.SCAN_CONTROLLER_PATH}/commit-range-zipped-file' ) files = {'file': ('multiple_files_scan.zip', zip_file.read())} - response = self.scan_cycode_client.post(url_path=url_path, data={'scan_id': scan_id}, files=files) + response = self.scan_cycode_client.post( + url_path=url_path, data={'scan_id': scan_id}, files=files, hide_response_content_log=self._hide_response_log + ) return self.parse_zipped_file_scan_response(response) def report_scan_status(self, scan_type: str, scan_id: str, scan_status: dict) -> None: diff --git a/cycode/cyclient/scan_config/scan_config_creator.py b/cycode/cyclient/scan_config/scan_config_creator.py index c138565f..f17be424 100644 --- a/cycode/cyclient/scan_config/scan_config_creator.py +++ b/cycode/cyclient/scan_config/scan_config_creator.py @@ -8,13 +8,13 @@ from cycode.cyclient.scan_config.scan_config_base import DefaultScanConfig, DevScanConfig -def create_scan_client(client_id: str, client_secret: str) -> ScanClient: +def create_scan_client(client_id: str, client_secret: str, hide_response_log: bool) -> ScanClient: if dev_mode: scan_cycode_client, scan_config = create_scan_for_dev_env() else: scan_cycode_client, scan_config = create_scan(client_id, client_secret) - return ScanClient(scan_cycode_client=scan_cycode_client, scan_config=scan_config) + return ScanClient(scan_cycode_client, scan_config, hide_response_log) def create_scan(client_id: str, client_secret: str) -> Tuple[CycodeTokenBasedClient, DefaultScanConfig]: diff --git a/tests/conftest.py b/tests/conftest.py index ae7d4cd4..e500395a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,7 +19,7 @@ @pytest.fixture(scope='session') def scan_client() -> ScanClient: - return create_scan_client(_CLIENT_ID, _CLIENT_SECRET) + return create_scan_client(_CLIENT_ID, _CLIENT_SECRET, hide_response_log=False) @pytest.fixture(scope='session') From 32616a8a1ee8e35216092cd419ccec9837523aea Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Mon, 10 Jul 2023 14:32:31 +0200 Subject: [PATCH 010/257] CM-25167 - Update package description (#139) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 292e53d8..14e1e605 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] name = "cycode" version = "0.0.0" # DON'T TOUCH. Placeholder. Will be filled automatically on poetry build from Git Tag -description = "Perform secrets/iac scans for your sources using Cycode's engine" +description = "Boost security in your dev lifecycle via SAST, SCA, Secrets & IaC scanning." keywords=["secret-scan", "cycode", "devops", "token", "secret", "security", "cycode", "code"] authors = ["Cycode "] license = "MIT" From be3a884ecad882ca0e13a010919f99e872423516 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Mon, 10 Jul 2023 14:53:51 +0200 Subject: [PATCH 011/257] CM-25168 - Add short alias for the output option (#140) --- .gitignore | 1 + README.md | 12 ++++++------ cycode/cli/main.py | 2 ++ 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 9b17b29b..7f27180f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .idea *.iml .env +.ruff_cache/ # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/README.md b/README.md index f2fbe808..5ea2bdc6 100644 --- a/README.md +++ b/README.md @@ -213,12 +213,12 @@ repos: The following are the options and commands available with the Cycode CLI application: -| Option | Description | -|--------------------------------|-------------------------------------------------------------------| -| `--output [text\|json\|table]` | Specify the output (`text`/`json`/`table`). The default is `text` | -| `-v`, `--verbose` | Show detailed logs | -| `--version` | Show the version and exit. | -| `--help` | Show options for given command. | +| Option | Description | +|--------------------------------------|-------------------------------------------------------------------| +| `-o`, `--output [text\|json\|table]` | Specify the output (`text`/`json`/`table`). The default is `text` | +| `-v`, `--verbose` | Show detailed logs | +| `--version` | Show the version and exit. | +| `--help` | Show options for given command. | | Command | Description | |-------------------------------------|-------------| diff --git a/cycode/cli/main.py b/cycode/cli/main.py index 823701d5..a8020e54 100644 --- a/cycode/cli/main.py +++ b/cycode/cli/main.py @@ -76,6 +76,7 @@ ) @click.option( '--output', + '-o', default=None, help=""" \b @@ -198,6 +199,7 @@ def finalize(context: click.Context, *_, **__) -> None: ) @click.option( '--output', + '-o', default='text', help='Specify the output (text/json/table), the default is text', type=click.Choice(['text', 'json', 'table']), From 9d91ff44713efb78e174f0288d55d121888d49ea Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 12 Jul 2023 17:58:06 +0200 Subject: [PATCH 012/257] CM-25249 - Optimize the local files collection and improve the progress bar stage (#141) --- cycode/cli/code_scanner.py | 60 +++++++----- cycode/cli/consts.py | 12 +-- .../user_settings/configuration_manager.py | 2 + cycode/cli/utils/path_utils.py | 31 +++--- cycode/cli/utils/progress_bar.py | 3 - tests/conftest.py | 5 + tests/test_files/.test_env | 1 + tests/test_performance_get_all_files.py | 94 +++++++++++++++++++ 8 files changed, 166 insertions(+), 42 deletions(-) create mode 100644 tests/test_files/.test_env create mode 100644 tests/test_performance_get_all_files.py diff --git a/cycode/cli/code_scanner.py b/cycode/cli/code_scanner.py index 65230537..15313d45 100644 --- a/cycode/cli/code_scanner.py +++ b/cycode/cli/code_scanner.py @@ -190,10 +190,32 @@ def scan_ci(context: click.Context) -> None: @click.pass_context def scan_path(context: click.Context, path: str) -> None: logger.debug('Starting path scan process, %s', {'path': path}) - files_to_scan = get_relevant_files_in_path(path=path, exclude_patterns=['**/.git/**', '**/.cycode/**']) - files_to_scan = exclude_irrelevant_files(context, files_to_scan) - logger.debug('Found all relevant files for scanning %s', {'path': path, 'file_to_scan_count': len(files_to_scan)}) - scan_disk_files(context, path, files_to_scan) + + progress_bar = context.obj['progress_bar'] + + all_files_to_scan = get_relevant_files_in_path(path=path, exclude_patterns=['**/.git/**', '**/.cycode/**']) + + # we are double the progress bar section length because we are going to process the files twice + # first time to get the file list with respect of excluded patterns (excluding takes seconds to execute) + # second time to get the files content + progress_bar_section_len = len(all_files_to_scan) * 2 + progress_bar.set_section_length(ProgressBarSection.PREPARE_LOCAL_FILES, progress_bar_section_len) + + relevant_files_to_scan = exclude_irrelevant_files(context, all_files_to_scan) + + # after finishing the first processing (excluding), + # we must update the progress bar stage with respect of excluded files. + # now it's possible that we will not process x2 of the files count + # because some of them were excluded, we should subtract the excluded files count + # from the progress bar section length + excluded_files_count = len(all_files_to_scan) - len(relevant_files_to_scan) + progress_bar_section_len = progress_bar_section_len - excluded_files_count + progress_bar.set_section_length(ProgressBarSection.PREPARE_LOCAL_FILES, progress_bar_section_len) + + logger.debug( + 'Found all relevant files for scanning %s', {'path': path, 'file_to_scan_count': len(relevant_files_to_scan)} + ) + scan_disk_files(context, path, relevant_files_to_scan) @click.command(short_help='Use this command to scan the content that was not committed yet') @@ -300,17 +322,15 @@ def scan_disk_files(context: click.Context, path: str, files_to_scan: List[str]) is_git_diff = False - progress_bar.set_section_length(ProgressBarSection.PREPARE_LOCAL_FILES, len(files_to_scan)) - documents: List[Document] = [] for file in files_to_scan: progress_bar.update(ProgressBarSection.PREPARE_LOCAL_FILES) - with open(file, 'r', encoding='UTF-8') as f: - try: - documents.append(Document(file, f.read(), is_git_diff)) - except UnicodeDecodeError: - continue + content = get_file_content(file) + if not content: + continue + + documents.append(Document(file, content, is_git_diff)) perform_pre_scan_documents_actions(context, scan_type, documents, is_git_diff) scan_documents(context, documents, is_git_diff=is_git_diff, scan_parameters=scan_parameters) @@ -826,12 +846,16 @@ def exclude_irrelevant_documents_to_scan(context: click.Context, documents_to_sc def exclude_irrelevant_files(context: click.Context, filenames: List[str]) -> List[str]: scan_type = context.obj['scan_type'] + progress_bar = context.obj['progress_bar'] relevant_files = [] for filename in filenames: + progress_bar.update(ProgressBarSection.PREPARE_LOCAL_FILES) if _is_relevant_file_to_scan(scan_type, filename): relevant_files.append(filename) + is_sub_path.cache_clear() # free up memory + return relevant_files @@ -1066,20 +1090,12 @@ def _is_file_extension_supported(scan_type: str, filename: str) -> bool: filename = filename.lower() if scan_type == consts.INFRA_CONFIGURATION_SCAN_TYPE: - return any( - filename.endswith(supported_file_extension) - for supported_file_extension in consts.INFRA_CONFIGURATION_SCAN_SUPPORTED_FILES - ) + return filename.endswith(consts.INFRA_CONFIGURATION_SCAN_SUPPORTED_FILES) if scan_type == consts.SCA_SCAN_TYPE: - return any( - filename.endswith(supported_file) for supported_file in consts.SCA_CONFIGURATION_SCAN_SUPPORTED_FILES - ) + return filename.endswith(consts.SCA_CONFIGURATION_SCAN_SUPPORTED_FILES) - return all( - not filename.endswith(file_extension_to_ignore) - for file_extension_to_ignore in consts.SECRET_SCAN_FILE_EXTENSIONS_TO_IGNORE - ) + return not filename.endswith(consts.SECRET_SCAN_FILE_EXTENSIONS_TO_IGNORE) def _does_file_exceed_max_size_limit(filename: str) -> bool: diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index eb3f2c00..71dcedf0 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -7,9 +7,9 @@ SCA_SCAN_TYPE = 'sca' SAST_SCAN_TYPE = 'sast' -INFRA_CONFIGURATION_SCAN_SUPPORTED_FILES = ['.tf', '.tf.json', '.json', '.yaml', '.yml', 'dockerfile'] +INFRA_CONFIGURATION_SCAN_SUPPORTED_FILES = ('.tf', '.tf.json', '.json', '.yaml', '.yml', 'dockerfile') -SECRET_SCAN_FILE_EXTENSIONS_TO_IGNORE = [ +SECRET_SCAN_FILE_EXTENSIONS_TO_IGNORE = ( '.7z', '.bmp', '.bz2', @@ -39,9 +39,9 @@ '.deb', '.obj', '.model', -] +) -SCA_CONFIGURATION_SCAN_SUPPORTED_FILES = [ +SCA_CONFIGURATION_SCAN_SUPPORTED_FILES = ( 'cargo.lock', 'cargo.toml', 'composer.json', @@ -73,9 +73,9 @@ 'pipfile.lock', 'requirements.txt', 'setup.py', -] +) -SCA_EXCLUDED_PATHS = ['node_modules'] +SCA_EXCLUDED_PATHS = ('node_modules',) PROJECT_FILES_BY_ECOSYSTEM_MAP = { 'crates': ['Cargo.lock', 'Cargo.toml'], diff --git a/cycode/cli/user_settings/configuration_manager.py b/cycode/cli/user_settings/configuration_manager.py index 947781da..98e62e07 100644 --- a/cycode/cli/user_settings/configuration_manager.py +++ b/cycode/cli/user_settings/configuration_manager.py @@ -1,4 +1,5 @@ import os +from functools import lru_cache from pathlib import Path from typing import Any, Dict, Optional from uuid import uuid4 @@ -61,6 +62,7 @@ def get_verbose_flag_from_environment_variables(self) -> bool: value = self._get_value_from_environment_variables(consts.VERBOSE_ENV_VAR_NAME, '') return value.lower() in ('true', '1') + @lru_cache(maxsize=None) # noqa: B019 def get_exclusions_by_scan_type(self, scan_type: str) -> Dict: local_exclusions = self.local_config_file_manager.get_exclusions_by_scan_type(scan_type) global_exclusions = self.global_config_file_manager.get_exclusions_by_scan_type(scan_type) diff --git a/cycode/cli/utils/path_utils.py b/cycode/cli/utils/path_utils.py index e25daede..ef758fed 100644 --- a/cycode/cli/utils/path_utils.py +++ b/cycode/cli/utils/path_utils.py @@ -1,6 +1,6 @@ import os -from pathlib import Path -from typing import AnyStr, Generator, Iterable, List, Optional +from functools import lru_cache +from typing import AnyStr, Iterable, List, Optional import pathspec from binaryornot.check import is_binary @@ -8,20 +8,24 @@ def get_relevant_files_in_path(path: str, exclude_patterns: Iterable[str]) -> List[str]: absolute_path = get_absolute_path(path) + if not os.path.isfile(absolute_path) and not os.path.isdir(absolute_path): - raise FileNotFoundError(f'the specified path was not found, path: {path}') + raise FileNotFoundError(f'the specified path was not found, path: {absolute_path}') if os.path.isfile(absolute_path): return [absolute_path] - directory_files_paths = _get_all_existing_files_in_directory(absolute_path) - file_paths = set({str(file_path) for file_path in directory_files_paths}) - spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, exclude_patterns) - exclude_file_paths = set(spec.match_files(file_paths)) + all_file_paths = set(_get_all_existing_files_in_directory(absolute_path)) + + path_spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, exclude_patterns) + excluded_file_paths = set(path_spec.match_files(all_file_paths)) + + relevant_file_paths = all_file_paths - excluded_file_paths - return [file_path for file_path in (file_paths - exclude_file_paths) if os.path.isfile(file_path)] + return [file_path for file_path in relevant_file_paths if os.path.isfile(file_path)] +@lru_cache(maxsize=None) def is_sub_path(path: str, sub_path: str) -> bool: try: common_path = os.path.commonpath([get_absolute_path(path), get_absolute_path(sub_path)]) @@ -49,9 +53,14 @@ def get_path_by_os(filename: str) -> str: return filename.replace('/', os.sep) -def _get_all_existing_files_in_directory(path: str) -> Generator[Path, None, None]: - directory = Path(path) - return directory.rglob(r'*') +def _get_all_existing_files_in_directory(path: str) -> List[str]: + files: List[str] = [] + + for root, _, filenames in os.walk(path): + for filename in filenames: + files.append(os.path.join(root, filename)) + + return files def is_path_exists(path: str) -> bool: diff --git a/cycode/cli/utils/progress_bar.py b/cycode/cli/utils/progress_bar.py index e340fb92..d22abf84 100644 --- a/cycode/cli/utils/progress_bar.py +++ b/cycode/cli/utils/progress_bar.py @@ -40,9 +40,6 @@ class ProgressBarSectionInfo(NamedTuple): ProgressBarSection.PREPARE_LOCAL_FILES: ProgressBarSectionInfo( ProgressBarSection.PREPARE_LOCAL_FILES, 'Prepare local files', start_percent=0, stop_percent=5 ), - # TODO(MarshalX): could be added in the future - # ProgressBarSection.UPLOAD_FILES: ProgressBarSectionInfo( - # ), ProgressBarSection.SCAN: ProgressBarSectionInfo( ProgressBarSection.SCAN, 'Scan in progress', start_percent=5, stop_percent=95 ), diff --git a/tests/conftest.py b/tests/conftest.py index e500395a..a763f6bb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,6 +17,11 @@ TEST_FILES_PATH = Path(__file__).parent.joinpath('test_files').absolute() +@pytest.fixture(scope='session') +def test_files_path() -> Path: + return TEST_FILES_PATH + + @pytest.fixture(scope='session') def scan_client() -> ScanClient: return create_scan_client(_CLIENT_ID, _CLIENT_SECRET, hide_response_log=False) diff --git a/tests/test_files/.test_env b/tests/test_files/.test_env new file mode 100644 index 00000000..3e58295a --- /dev/null +++ b/tests/test_files/.test_env @@ -0,0 +1 @@ +TELEGRAM_BOT_TOKEN=923445010:AAGWKwWTNx_6RAuRdcp2kWax5_JltwkF2Lw diff --git a/tests/test_performance_get_all_files.py b/tests/test_performance_get_all_files.py new file mode 100644 index 00000000..b10b86e7 --- /dev/null +++ b/tests/test_performance_get_all_files.py @@ -0,0 +1,94 @@ +import glob +import logging +import os +import timeit +from pathlib import Path +from typing import Dict, List, Tuple, Union + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + + +def filter_files(paths: List[Union[Path, str]]) -> List[str]: + return [str(path) for path in paths if os.path.isfile(path)] + + +def get_all_files_glob(path: Union[Path, str]) -> List[str]: + # DOESN'T RETURN HIDDEN FILES. CAN'T BE USED + # and doesn't show the best performance + if not str(path).endswith(os.sep): + path = f'{path}{os.sep}' + + return filter_files(glob.glob(f'{path}**', recursive=True)) + + +def get_all_files_walk(path: str) -> List[str]: + files = [] + + for root, _, filenames in os.walk(path): + for filename in filenames: + files.append(os.path.join(root, filename)) + + return files + + +def get_all_files_listdir(path: str) -> List[str]: + files = [] + + def _(sub_path: str) -> None: + items = os.listdir(sub_path) + + for item in items: + item_path = os.path.join(sub_path, item) + + if os.path.isfile(item_path): + files.append(item_path) + elif os.path.isdir(item_path): + _(item_path) + + _(path) + return files + + +def get_all_files_rglob(path: str) -> List[str]: + return filter_files(list(Path(path).rglob(r'*'))) + + +def test_get_all_files_performance(test_files_path: str) -> None: + results: Dict[str, Tuple[int, float]] = {} + for func in { + get_all_files_rglob, + get_all_files_listdir, + get_all_files_walk, + }: + name = func.__name__ + start_time = timeit.default_timer() + + files_count = len(func(test_files_path)) + + executed_time = timeit.default_timer() - start_time + results[name] = (files_count, executed_time) + + logger.info(f'Time result {name}: {executed_time}') + logger.info(f'Files count {name}: {files_count}') + + files_counts = [result[0] for result in results.values()] + assert len(set(files_counts)) == 1 # all should be equal + + logger.info(f'Benchmark TOP with ({files_counts[0]}) files:') + for func_name, result in sorted(results.items(), key=lambda x: x[1][1]): + logger.info(f'- {func_name}: {result[1]}') + + # according to my (MarshalX) local tests, the fastest is get_all_files_walk + + +if __name__ == '__main__': + # provide a path with thousands of files + huge_dir_path = '/Users/ilyasiamionau/projects/cycode/' + test_get_all_files_performance(huge_dir_path) + + # Output: + # INFO:__main__:Benchmark TOP with (94882) files: + # INFO:__main__:- get_all_files_walk: 0.717258458 + # INFO:__main__:- get_all_files_listdir: 1.4648628330000002 + # INFO:__main__:- get_all_files_rglob: 2.368291458 From db748c49a54eb839031a17990cd68f749f27f8ea Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 18 Jul 2023 17:22:15 +0200 Subject: [PATCH 013/257] CM-25361 - Add version command that supports TEXT and JSON output formats (#142) --- cycode/cli/consts.py | 2 ++ cycode/cli/main.py | 29 ++++++++++++++++++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 71dcedf0..6d710b1b 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -1,3 +1,5 @@ +PROGRAM_NAME = 'cycode' + PRE_COMMIT_COMMAND_SCAN_TYPE = 'pre_commit' PRE_RECEIVE_COMMAND_SCAN_TYPE = 'pre_receive' COMMIT_HISTORY_COMMAND_SCAN_TYPE = 'commit_history' diff --git a/cycode/cli/main.py b/cycode/cli/main.py index a8020e54..3fd99f7f 100644 --- a/cycode/cli/main.py +++ b/cycode/cli/main.py @@ -1,3 +1,4 @@ +import json import logging import sys from typing import TYPE_CHECKING, List, Optional, Tuple @@ -8,7 +9,7 @@ from cycode.cli import code_scanner from cycode.cli.auth.auth_command import authenticate from cycode.cli.config import config -from cycode.cli.consts import ISSUE_DETECTED_STATUS_CODE, NO_ISSUES_STATUS_CODE +from cycode.cli.consts import ISSUE_DETECTED_STATUS_CODE, NO_ISSUES_STATUS_CODE, PROGRAM_NAME from cycode.cli.models import Severity from cycode.cli.user_settings.configuration_manager import ConfigurationManager from cycode.cli.user_settings.credentials_manager import CredentialsManager @@ -180,8 +181,30 @@ def finalize(context: click.Context, *_, **__) -> None: sys.exit(exit_code) +@click.command(short_help='Show the version and exit') +@click.pass_context +def version(context: click.Context) -> None: + output = context.obj['output'] + + prog = PROGRAM_NAME + ver = __version__ + + message = f'{prog}, version {ver}' + if output == 'json': + message = json.dumps({'name': prog, 'version': ver}) + + click.echo(message, color=context.color) + context.exit() + + @click.group( - commands={'scan': code_scan, 'configure': set_credentials, 'ignore': add_exclusions, 'auth': authenticate}, + commands={ + 'scan': code_scan, + 'configure': set_credentials, + 'ignore': add_exclusions, + 'auth': authenticate, + 'version': version, + }, context_settings=CONTEXT, ) @click.option( @@ -210,7 +233,7 @@ def finalize(context: click.Context, *_, **__) -> None: help='Characteristic JSON object that lets servers identify the application', type=str, ) -@click.version_option(__version__, prog_name='cycode') +@click.version_option(__version__, prog_name=PROGRAM_NAME) @click.pass_context def main_cli( context: click.Context, verbose: bool, no_progress_meter: bool, output: str, user_agent: Optional[str] From 4b7bc8c3d25b0d9498712b31d2c62ae6158f687b Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 18 Jul 2023 17:59:16 +0200 Subject: [PATCH 014/257] CM-25340 - Fix list modification while in a for loop (#143) --- cycode/cli/helpers/sca_code_scanner.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/cycode/cli/helpers/sca_code_scanner.py b/cycode/cli/helpers/sca_code_scanner.py index ccff11b0..227b553e 100644 --- a/cycode/cli/helpers/sca_code_scanner.py +++ b/cycode/cli/helpers/sca_code_scanner.py @@ -43,13 +43,16 @@ def perform_pre_hook_range_scan_actions( def add_ecosystem_related_files_if_exists( documents: List[Document], repo: Optional[Repo] = None, commit_rev: Optional[str] = None ) -> None: + documents_to_add: List[Document] = [] for doc in documents: ecosystem = get_project_file_ecosystem(doc) if ecosystem is None: logger.debug('failed to resolve project file ecosystem: %s', doc.path) continue - documents_to_add = get_doc_ecosystem_related_project_files(doc, documents, ecosystem, commit_rev, repo) - documents.extend(documents_to_add) + + documents_to_add.extend(get_doc_ecosystem_related_project_files(doc, documents, ecosystem, commit_rev, repo)) + + documents.extend(documents_to_add) def get_doc_ecosystem_related_project_files( @@ -59,11 +62,10 @@ def get_doc_ecosystem_related_project_files( for ecosystem_project_file in consts.PROJECT_FILES_BY_ECOSYSTEM_MAP.get(ecosystem): file_to_search = join_paths(get_file_dir(doc.path), ecosystem_project_file) if not is_project_file_exists_in_documents(documents, file_to_search): - file_content = ( - get_file_content_from_commit(repo, commit_rev, file_to_search) - if repo - else get_file_content(file_to_search) - ) + if repo: + file_content = get_file_content_from_commit(repo, commit_rev, file_to_search) + else: + file_content = get_file_content(file_to_search) if file_content is not None: documents_to_add.append(Document(file_to_search, file_content)) From 4937adcb556799044d4dbfab448a468aaf6322dd Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 18 Jul 2023 18:19:57 +0200 Subject: [PATCH 015/257] CM-25271 - Improve version managing of pre-commit hook (#144) Fix shallow repository --- cycode/__init__.py | 14 -------------- cycode/pre-commit-hook-version | 1 - pyproject.toml | 5 +++-- 3 files changed, 3 insertions(+), 17 deletions(-) delete mode 100644 cycode/pre-commit-hook-version diff --git a/cycode/__init__.py b/cycode/__init__.py index 2b478887..4ce71ef1 100644 --- a/cycode/__init__.py +++ b/cycode/__init__.py @@ -1,15 +1 @@ __version__ = '0.0.0' # DON'T TOUCH. Placeholder. Will be filled automatically on poetry build from Git Tag - -if __version__ == '0.0.1.dev1': - # If CLI was installed from shallow clone, __version__ will be 0.0.1.dev1 due to non-strict versioning. - # This happens when installing CLI as pre-commit hook. - # We are not able to provide the version based on Git Tag in this case. - # This fallback version is maintained manually. - - # One benefit of it is that we could pass the version with a special suffix to mark pre-commit hook usage. - - import os - - version_filepath = os.path.join(os.path.dirname(__file__), 'pre-commit-hook-version') - with open(version_filepath, 'r', encoding='UTF-8') as f: - __version__ = f.read().strip() diff --git a/cycode/pre-commit-hook-version b/cycode/pre-commit-hook-version deleted file mode 100644 index d1e8df28..00000000 --- a/cycode/pre-commit-hook-version +++ /dev/null @@ -1 +0,0 @@ -0.2.5-pre-commit \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 14e1e605..4c093047 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,9 +59,10 @@ log_cli = true [tool.poetry-dynamic-versioning] # poetry self add "poetry-dynamic-versioning[plugin]" enable = true -strict = false +strict = true bump = true metadata = false +fix-shallow-repository=true vcs = "git" style = "pep440" @@ -134,5 +135,5 @@ inline-quotes = "single" "cycode/*.py" = ["BLE001"] [build-system] -requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning"] +requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=0.25.0"] build-backend = "poetry_dynamic_versioning.backend" From 7d52a5a33521a30d60fd8b91023f909c5892c45b Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 18 Jul 2023 19:02:46 +0200 Subject: [PATCH 016/257] CM-25171 - Add Poetry installation cache (#145) --- .github/workflows/black.yml | 11 +++++++++++ .github/workflows/build_executable.yml | 11 +++++++++++ .github/workflows/pre_release.yml | 11 +++++++++++ .github/workflows/release.yml | 11 +++++++++++ .github/workflows/ruff.yml | 11 +++++++++++ .github/workflows/tests.yml | 11 +++++++++++ .github/workflows/tests_full.yml | 11 +++++++++++ 7 files changed, 77 insertions(+) diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml index 05208c66..917994f5 100644 --- a/.github/workflows/black.yml +++ b/.github/workflows/black.yml @@ -26,9 +26,20 @@ jobs: with: python-version: 3.7 + - name: Load cached Poetry setup + id: cached-poetry + uses: actions/cache@v3 + with: + path: ~/.local + key: poetry-ubuntu-0 # increment to reset cache + - name: Setup Poetry + if: steps.cached-poetry.outputs.cache-hit != 'true' uses: snok/install-poetry@v1 + - name: Add Poetry to PATH + run: echo "$HOME/.local/bin" >> $GITHUB_PATH + - name: Install dependencies run: poetry install diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index ed522efd..4f0417a8 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -41,9 +41,20 @@ jobs: with: python-version: '3.7' + - name: Load cached Poetry setup + id: cached-poetry + uses: actions/cache@v3 + with: + path: ~/.local + key: poetry-${{ matrix.os }}-0 # increment to reset cache + - name: Setup Poetry + if: steps.cached-poetry.outputs.cache-hit != 'true' uses: snok/install-poetry@v1 + - name: Add Poetry to PATH + run: echo "$HOME/.local/bin" >> $GITHUB_PATH + - name: Install dependencies run: poetry install diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 13ae474b..a2734ca4 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -36,9 +36,20 @@ jobs: with: python-version: '3.7' + - name: Load cached Poetry setup + id: cached-poetry + uses: actions/cache@v3 + with: + path: ~/.local + key: poetry-ubuntu-0 # increment to reset cache + - name: Setup Poetry + if: steps.cached-poetry.outputs.cache-hit != 'true' uses: snok/install-poetry@v1 + - name: Add Poetry to PATH + run: echo "$HOME/.local/bin" >> $GITHUB_PATH + - name: Install Poetry Plugin run: poetry self add "poetry-dynamic-versioning[plugin]" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 70a60c33..91eeb315 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,9 +35,20 @@ jobs: with: python-version: '3.7' + - name: Load cached Poetry setup + id: cached-poetry + uses: actions/cache@v3 + with: + path: ~/.local + key: poetry-ubuntu-0 # increment to reset cache + - name: Setup Poetry + if: steps.cached-poetry.outputs.cache-hit != 'true' uses: snok/install-poetry@v1 + - name: Add Poetry to PATH + run: echo "$HOME/.local/bin" >> $GITHUB_PATH + - name: Install Poetry Plugin run: poetry self add "poetry-dynamic-versioning[plugin]" diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 0fc3ddb5..6ef7cefd 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -26,9 +26,20 @@ jobs: with: python-version: 3.7 + - name: Load cached Poetry setup + id: cached-poetry + uses: actions/cache@v3 + with: + path: ~/.local + key: poetry-ubuntu-0 # increment to reset cache + - name: Setup Poetry + if: steps.cached-poetry.outputs.cache-hit != 'true' uses: snok/install-poetry@v1 + - name: Add Poetry to PATH + run: echo "$HOME/.local/bin" >> $GITHUB_PATH + - name: Install dependencies run: poetry install diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 27450b71..5b6db7c7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,9 +29,20 @@ jobs: with: python-version: '3.7' + - name: Load cached Poetry setup + id: cached-poetry + uses: actions/cache@v3 + with: + path: ~/.local + key: poetry-ubuntu-0 # increment to reset cache + - name: Setup Poetry + if: steps.cached-poetry.outputs.cache-hit != 'true' uses: snok/install-poetry@v1 + - name: Add Poetry to PATH + run: echo "$HOME/.local/bin" >> $GITHUB_PATH + - name: Install dependencies run: poetry install diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index bdcc49d4..461e1231 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -42,9 +42,20 @@ jobs: with: python-version: ${{ matrix.python-version }} + - name: Load cached Poetry setup + id: cached-poetry + uses: actions/cache@v3 + with: + path: ~/.local + key: poetry-${{ matrix.os }}-0 # increment to reset cache + - name: Setup Poetry + if: steps.cached-poetry.outputs.cache-hit != 'true' uses: snok/install-poetry@v1 + - name: Add Poetry to PATH + run: echo "$HOME/.local/bin" >> $GITHUB_PATH + - name: Install dependencies run: poetry install From b17d9b2bcf8be9d716f07bf9846fab08618980e6 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Mon, 24 Jul 2023 14:57:47 +0400 Subject: [PATCH 017/257] CM-24116 - Migrate SCA tables to the new table management system (#146) --- cycode/cli/printers/console_printer.py | 14 +- cycode/cli/printers/json_printer.py | 4 +- .../{base_printer.py => printer_base.py} | 2 +- cycode/cli/printers/sca_table_printer.py | 156 ------------------ cycode/cli/printers/tables/__init__.py | 0 .../cli/printers/tables/sca_table_printer.py | 143 ++++++++++++++++ cycode/cli/printers/{ => tables}/table.py | 2 +- .../cli/printers/{ => tables}/table_models.py | 10 +- .../printers/{ => tables}/table_printer.py | 40 +++-- .../table_printer_base.py} | 10 +- cycode/cli/printers/text_printer.py | 4 +- 11 files changed, 190 insertions(+), 195 deletions(-) rename cycode/cli/printers/{base_printer.py => printer_base.py} (96%) delete mode 100644 cycode/cli/printers/sca_table_printer.py create mode 100644 cycode/cli/printers/tables/__init__.py create mode 100644 cycode/cli/printers/tables/sca_table_printer.py rename cycode/cli/printers/{ => tables}/table.py (96%) rename cycode/cli/printers/{ => tables}/table_models.py (62%) rename cycode/cli/printers/{ => tables}/table_printer.py (79%) rename cycode/cli/printers/{base_table_printer.py => tables/table_printer_base.py} (82%) diff --git a/cycode/cli/printers/console_printer.py b/cycode/cli/printers/console_printer.py index 533ed321..f0bccadf 100644 --- a/cycode/cli/printers/console_printer.py +++ b/cycode/cli/printers/console_printer.py @@ -5,23 +5,23 @@ from cycode.cli.exceptions.custom_exceptions import CycodeError from cycode.cli.models import CliError, CliResult from cycode.cli.printers.json_printer import JsonPrinter -from cycode.cli.printers.sca_table_printer import SCATablePrinter -from cycode.cli.printers.table_printer import TablePrinter +from cycode.cli.printers.tables.sca_table_printer import ScaTablePrinter +from cycode.cli.printers.tables.table_printer import TablePrinter from cycode.cli.printers.text_printer import TextPrinter if TYPE_CHECKING: from cycode.cli.models import LocalScanResult - from cycode.cli.printers.base_printer import BasePrinter + from cycode.cli.printers.tables.table_printer_base import PrinterBase class ConsolePrinter: - _AVAILABLE_PRINTERS: ClassVar[Dict[str, 'BasePrinter']] = { + _AVAILABLE_PRINTERS: ClassVar[Dict[str, 'PrinterBase']] = { 'text': TextPrinter, 'json': JsonPrinter, 'table': TablePrinter, # overrides - 'table_sca': SCATablePrinter, - 'text_sca': SCATablePrinter, + 'table_sca': ScaTablePrinter, + 'text_sca': ScaTablePrinter, } def __init__(self, context: click.Context) -> None: @@ -37,7 +37,7 @@ def print_scan_results(self, local_scan_results: List['LocalScanResult']) -> Non printer = self._get_scan_printer() printer.print_scan_results(local_scan_results) - def _get_scan_printer(self) -> 'BasePrinter': + def _get_scan_printer(self) -> 'PrinterBase': printer_class = self._printer_class composite_printer = self._AVAILABLE_PRINTERS.get(f'{self.output_type}_{self.scan_type}') diff --git a/cycode/cli/printers/json_printer.py b/cycode/cli/printers/json_printer.py index 52df39ea..2f048ae7 100644 --- a/cycode/cli/printers/json_printer.py +++ b/cycode/cli/printers/json_printer.py @@ -4,14 +4,14 @@ import click from cycode.cli.models import CliError, CliResult -from cycode.cli.printers.base_printer import BasePrinter +from cycode.cli.printers.printer_base import PrinterBase from cycode.cyclient.models import DetectionSchema if TYPE_CHECKING: from cycode.cli.models import LocalScanResult -class JsonPrinter(BasePrinter): +class JsonPrinter(PrinterBase): def print_result(self, result: CliResult) -> None: result = {'result': result.success, 'message': result.message} diff --git a/cycode/cli/printers/base_printer.py b/cycode/cli/printers/printer_base.py similarity index 96% rename from cycode/cli/printers/base_printer.py rename to cycode/cli/printers/printer_base.py index ceb430ab..69802e54 100644 --- a/cycode/cli/printers/base_printer.py +++ b/cycode/cli/printers/printer_base.py @@ -9,7 +9,7 @@ from cycode.cli.models import LocalScanResult -class BasePrinter(ABC): +class PrinterBase(ABC): RED_COLOR_NAME = 'red' WHITE_COLOR_NAME = 'white' GREEN_COLOR_NAME = 'green' diff --git a/cycode/cli/printers/sca_table_printer.py b/cycode/cli/printers/sca_table_printer.py deleted file mode 100644 index 80d2da4f..00000000 --- a/cycode/cli/printers/sca_table_printer.py +++ /dev/null @@ -1,156 +0,0 @@ -from collections import defaultdict -from typing import TYPE_CHECKING, Dict, List - -import click -from texttable import Texttable - -from cycode.cli.consts import LICENSE_COMPLIANCE_POLICY_ID, PACKAGE_VULNERABILITY_POLICY_ID -from cycode.cli.models import Detection -from cycode.cli.printers.base_table_printer import BaseTablePrinter -from cycode.cli.utils.string_utils import shortcut_dependency_paths - -if TYPE_CHECKING: - from cycode.cli.models import LocalScanResult - -SEVERITY_COLUMN = 'Severity' -LICENSE_COLUMN = 'License' -UPGRADE_COLUMN = 'Upgrade' -REPOSITORY_COLUMN = 'Repository' -CVE_COLUMN = 'CVE' - -PREVIEW_DETECTIONS_COMMON_HEADERS = [ - 'File Path', - 'Ecosystem', - 'Dependency Name', - 'Direct Dependency', - 'Development Dependency', - 'Dependency Paths', -] - - -class SCATablePrinter(BaseTablePrinter): - def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: - detections_per_detection_type_id = self._extract_detections_per_detection_type_id(local_scan_results) - self._print_detection_per_detection_type_id(detections_per_detection_type_id) - - @staticmethod - def _extract_detections_per_detection_type_id( - local_scan_results: List['LocalScanResult'], - ) -> Dict[str, List[Detection]]: - detections_per_detection_type_id = defaultdict(list) - - for local_scan_result in local_scan_results: - for document_detection in local_scan_result.document_detections: - for detection in document_detection.detections: - detections_per_detection_type_id[detection.detection_type_id].append(detection) - - return detections_per_detection_type_id - - def _print_detection_per_detection_type_id( - self, detections_per_detection_type_id: Dict[str, List[Detection]] - ) -> None: - for detection_type_id in detections_per_detection_type_id: - detections = detections_per_detection_type_id[detection_type_id] - headers = self._get_table_headers() - - title = None - rows = [] - - if detection_type_id == PACKAGE_VULNERABILITY_POLICY_ID: - title = 'Dependencies Vulnerabilities' - - headers = [SEVERITY_COLUMN, *headers] - headers.extend(PREVIEW_DETECTIONS_COMMON_HEADERS) - headers.append(CVE_COLUMN) - headers.append(UPGRADE_COLUMN) - - for detection in detections: - rows.append(self._get_upgrade_package_vulnerability(detection)) - elif detection_type_id == LICENSE_COMPLIANCE_POLICY_ID: - title = 'License Compliance' - - headers.extend(PREVIEW_DETECTIONS_COMMON_HEADERS) - headers.append(LICENSE_COLUMN) - - for detection in detections: - rows.append(self._get_license(detection)) - - if rows: - self._print_table_detections(detections, headers, rows, title) - - def _get_table_headers(self) -> list: - if self._is_git_repository(): - return [REPOSITORY_COLUMN] - - return [] - - def _print_table_detections( - self, detections: List[Detection], headers: List[str], rows: List[List[str]], title: str - ) -> None: - self._print_summary_issues(detections, title) - text_table = Texttable() - text_table.header(headers) - - self.set_table_width(headers, text_table) - - for row in rows: - text_table.add_row(row) - - click.echo(text_table.draw()) - - @staticmethod - def set_table_width(headers: List[str], text_table: Texttable) -> None: - header_width_size_cols = [] - for header in headers: - header_len = len(header) - if header == CVE_COLUMN: - header_width_size_cols.append(header_len * 5) - elif header == UPGRADE_COLUMN: - header_width_size_cols.append(header_len * 2) - else: - header_width_size_cols.append(header_len) - text_table.set_cols_width(header_width_size_cols) - - @staticmethod - def _print_summary_issues(detections: List, title: str) -> None: - click.echo(f'⛔ Found {len(detections)} issues of type: {click.style(title, bold=True)}') - - def _get_common_detection_fields(self, detection: Detection) -> List[str]: - dependency_paths = 'N/A' - dependency_paths_raw = detection.detection_details.get('dependency_paths') - if dependency_paths_raw: - dependency_paths = shortcut_dependency_paths(dependency_paths_raw) - - row = [ - detection.detection_details.get('file_name'), - detection.detection_details.get('ecosystem'), - detection.detection_details.get('package_name'), - detection.detection_details.get('is_direct_dependency_str'), - detection.detection_details.get('is_dev_dependency_str'), - dependency_paths, - ] - - if self._is_git_repository(): - row = [detection.detection_details.get('repository_name'), *row] - - return row - - def _get_upgrade_package_vulnerability(self, detection: Detection) -> List[str]: - alert = detection.detection_details.get('alert') - row = [ - detection.detection_details.get('advisory_severity'), - *self._get_common_detection_fields(detection), - detection.detection_details.get('vulnerability_id'), - ] - - upgrade = '' - if alert.get('first_patched_version'): - upgrade = f'{alert.get("vulnerable_requirements")} -> {alert.get("first_patched_version")}' - row.append(upgrade) - - return row - - def _get_license(self, detection: Detection) -> List[str]: - row = self._get_common_detection_fields(detection) - row.append(f'{detection.detection_details.get("license")}') - return row diff --git a/cycode/cli/printers/tables/__init__.py b/cycode/cli/printers/tables/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/printers/tables/sca_table_printer.py b/cycode/cli/printers/tables/sca_table_printer.py new file mode 100644 index 00000000..da0ac8ac --- /dev/null +++ b/cycode/cli/printers/tables/sca_table_printer.py @@ -0,0 +1,143 @@ +from collections import defaultdict +from typing import TYPE_CHECKING, Dict, List + +import click + +from cycode.cli.consts import LICENSE_COMPLIANCE_POLICY_ID, PACKAGE_VULNERABILITY_POLICY_ID +from cycode.cli.models import Detection +from cycode.cli.printers.tables.table import Table +from cycode.cli.printers.tables.table_models import ColumnInfoBuilder, ColumnWidths +from cycode.cli.printers.tables.table_printer_base import TablePrinterBase +from cycode.cli.utils.string_utils import shortcut_dependency_paths + +if TYPE_CHECKING: + from cycode.cli.models import LocalScanResult + + +column_builder = ColumnInfoBuilder() + +# Building must have strict order. Represents the order of the columns in the table (from left to right) +SEVERITY_COLUMN = column_builder.build(name='Severity') +REPOSITORY_COLUMN = column_builder.build(name='Repository') + +FILE_PATH_COLUMN = column_builder.build(name='File Path') +ECOSYSTEM_COLUMN = column_builder.build(name='Ecosystem') +DEPENDENCY_NAME_COLUMN = column_builder.build(name='Dependency Name') +DIRECT_DEPENDENCY_COLUMN = column_builder.build(name='Direct Dependency') +DEVELOPMENT_DEPENDENCY_COLUMN = column_builder.build(name='Development Dependency') +DEPENDENCY_PATHS_COLUMN = column_builder.build(name='Dependency Paths') + +CVE_COLUMNS = column_builder.build(name='CVE') +UPGRADE_COLUMN = column_builder.build(name='Upgrade') +LICENSE_COLUMN = column_builder.build(name='License') + +COLUMN_WIDTHS_CONFIG: ColumnWidths = { + REPOSITORY_COLUMN: 2, + FILE_PATH_COLUMN: 3, + CVE_COLUMNS: 5, + UPGRADE_COLUMN: 3, + LICENSE_COLUMN: 2, +} + + +class ScaTablePrinter(TablePrinterBase): + def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: + detections_per_policy_id = self._extract_detections_per_policy_id(local_scan_results) + for policy_id, detections in detections_per_policy_id.items(): + table = self._get_table(policy_id) + table.set_cols_width(COLUMN_WIDTHS_CONFIG) + + for detection in detections: + self._enrich_table_with_values(table, detection) + + self._print_summary_issues(len(detections), self._get_title(policy_id)) + self._print_table(table) + + self._print_report_urls(local_scan_results) + + @staticmethod + def _get_title(policy_id: str) -> str: + if policy_id == PACKAGE_VULNERABILITY_POLICY_ID: + return 'Dependencies Vulnerabilities' + if policy_id == LICENSE_COMPLIANCE_POLICY_ID: + return 'License Compliance' + + return 'Unknown' + + def _get_table(self, policy_id: str) -> Table: + table = Table() + + if policy_id == PACKAGE_VULNERABILITY_POLICY_ID: + table.add(SEVERITY_COLUMN) + table.add(CVE_COLUMNS) + table.add(UPGRADE_COLUMN) + elif policy_id == LICENSE_COMPLIANCE_POLICY_ID: + table.add(LICENSE_COLUMN) + + if self._is_git_repository(): + table.add(REPOSITORY_COLUMN) + + table.add(FILE_PATH_COLUMN) + table.add(ECOSYSTEM_COLUMN) + table.add(DEPENDENCY_NAME_COLUMN) + table.add(DIRECT_DEPENDENCY_COLUMN) + table.add(DEVELOPMENT_DEPENDENCY_COLUMN) + table.add(DEPENDENCY_PATHS_COLUMN) + + return table + + @staticmethod + def _enrich_table_with_values(table: Table, detection: Detection) -> None: + detection_details = detection.detection_details + + table.set(SEVERITY_COLUMN, detection_details.get('advisory_severity')) + table.set(REPOSITORY_COLUMN, detection_details.get('repository_name')) + + table.set(FILE_PATH_COLUMN, detection_details.get('file_name')) + table.set(ECOSYSTEM_COLUMN, detection_details.get('ecosystem')) + table.set(DEPENDENCY_NAME_COLUMN, detection_details.get('package_name')) + table.set(DIRECT_DEPENDENCY_COLUMN, detection_details.get('is_direct_dependency_str')) + table.set(DEVELOPMENT_DEPENDENCY_COLUMN, detection_details.get('is_dev_dependency_str')) + + dependency_paths = 'N/A' + dependency_paths_raw = detection_details.get('dependency_paths') + if dependency_paths_raw: + dependency_paths = shortcut_dependency_paths(dependency_paths_raw) + table.set(DEPENDENCY_PATHS_COLUMN, dependency_paths) + + upgrade = '' + alert = detection_details.get('alert') + if alert and alert.get('first_patched_version'): + upgrade = f'{alert.get("vulnerable_requirements")} -> {alert.get("first_patched_version")}' + table.set(UPGRADE_COLUMN, upgrade) + + table.set(CVE_COLUMNS, detection_details.get('vulnerability_id')) + table.set(LICENSE_COLUMN, detection_details.get('license')) + + @staticmethod + def _print_report_urls(local_scan_results: List['LocalScanResult']) -> None: + report_urls = [scan_result.report_url for scan_result in local_scan_results if scan_result.report_url] + if not report_urls: + return + + click.echo('Report URLs:') + for report_url in report_urls: + click.echo(f'- {report_url}') + + @staticmethod + def _print_summary_issues(detections_count: int, title: str) -> None: + click.echo(f'⛔ Found {detections_count} issues of type: {click.style(title, bold=True)}') + + @staticmethod + def _extract_detections_per_policy_id( + local_scan_results: List['LocalScanResult'], + ) -> Dict[str, List[Detection]]: + detections_to_policy_id = defaultdict(list) + + for local_scan_result in local_scan_results: + for document_detection in local_scan_result.document_detections: + for detection in document_detection.detections: + detections_to_policy_id[detection.detection_type_id].append(detection) + + # sort dict by keys (policy id) to make persist output order + return dict(sorted(detections_to_policy_id.items(), reverse=True)) diff --git a/cycode/cli/printers/table.py b/cycode/cli/printers/tables/table.py similarity index 96% rename from cycode/cli/printers/table.py rename to cycode/cli/printers/tables/table.py index 9c90c940..2017b9c8 100644 --- a/cycode/cli/printers/table.py +++ b/cycode/cli/printers/tables/table.py @@ -3,7 +3,7 @@ from texttable import Texttable if TYPE_CHECKING: - from cycode.cli.printers.table_models import ColumnInfo, ColumnWidths + from cycode.cli.printers.tables.table_models import ColumnInfo, ColumnWidths class Table: diff --git a/cycode/cli/printers/table_models.py b/cycode/cli/printers/tables/table_models.py similarity index 62% rename from cycode/cli/printers/table_models.py rename to cycode/cli/printers/tables/table_models.py index e3dc195d..c162a8ce 100644 --- a/cycode/cli/printers/table_models.py +++ b/cycode/cli/printers/tables/table_models.py @@ -2,12 +2,12 @@ class ColumnInfoBuilder: - _index = 0 + def __init__(self) -> None: + self._index = 0 - @staticmethod - def build(name: str) -> 'ColumnInfo': - column_info = ColumnInfo(name, ColumnInfoBuilder._index) - ColumnInfoBuilder._index += 1 + def build(self, name: str) -> 'ColumnInfo': + column_info = ColumnInfo(name, self._index) + self._index += 1 return column_info diff --git a/cycode/cli/printers/table_printer.py b/cycode/cli/printers/tables/table_printer.py similarity index 79% rename from cycode/cli/printers/table_printer.py rename to cycode/cli/printers/tables/table_printer.py index c5a01201..2aa2ca4e 100644 --- a/cycode/cli/printers/table_printer.py +++ b/cycode/cli/printers/tables/table_printer.py @@ -4,26 +4,28 @@ from cycode.cli.consts import INFRA_CONFIGURATION_SCAN_TYPE, SAST_SCAN_TYPE, SECRET_SCAN_TYPE from cycode.cli.models import Detection, Document -from cycode.cli.printers.base_table_printer import BaseTablePrinter -from cycode.cli.printers.table import Table -from cycode.cli.printers.table_models import ColumnInfoBuilder, ColumnWidthsConfig +from cycode.cli.printers.tables.table import Table +from cycode.cli.printers.tables.table_models import ColumnInfoBuilder, ColumnWidthsConfig +from cycode.cli.printers.tables.table_printer_base import TablePrinterBase from cycode.cli.utils.string_utils import get_position_in_line, obfuscate_text if TYPE_CHECKING: from cycode.cli.models import LocalScanResult -# Creation must have strict order. Represents the order of the columns in the table (from left to right) -ISSUE_TYPE_COLUMN = ColumnInfoBuilder.build(name='Issue Type') -RULE_ID_COLUMN = ColumnInfoBuilder.build(name='Rule ID') -FILE_PATH_COLUMN = ColumnInfoBuilder.build(name='File Path') -SECRET_SHA_COLUMN = ColumnInfoBuilder.build(name='Secret SHA') -COMMIT_SHA_COLUMN = ColumnInfoBuilder.build(name='Commit SHA') -LINE_NUMBER_COLUMN = ColumnInfoBuilder.build(name='Line Number') -COLUMN_NUMBER_COLUMN = ColumnInfoBuilder.build(name='Column Number') -VIOLATION_LENGTH_COLUMN = ColumnInfoBuilder.build(name='Violation Length') -VIOLATION_COLUMN = ColumnInfoBuilder.build(name='Violation') -SCAN_ID_COLUMN = ColumnInfoBuilder.build(name='Scan ID') -REPORT_URL_COLUMN = ColumnInfoBuilder.build(name='Report URL') +column_builder = ColumnInfoBuilder() + +# Building must have strict order. Represents the order of the columns in the table (from left to right) +ISSUE_TYPE_COLUMN = column_builder.build(name='Issue Type') +RULE_ID_COLUMN = column_builder.build(name='Rule ID') +FILE_PATH_COLUMN = column_builder.build(name='File Path') +SECRET_SHA_COLUMN = column_builder.build(name='Secret SHA') +COMMIT_SHA_COLUMN = column_builder.build(name='Commit SHA') +LINE_NUMBER_COLUMN = column_builder.build(name='Line Number') +COLUMN_NUMBER_COLUMN = column_builder.build(name='Column Number') +VIOLATION_LENGTH_COLUMN = column_builder.build(name='Violation Length') +VIOLATION_COLUMN = column_builder.build(name='Violation') +SCAN_ID_COLUMN = column_builder.build(name='Scan ID') +REPORT_URL_COLUMN = column_builder.build(name='Report URL') COLUMN_WIDTHS_CONFIG: ColumnWidthsConfig = { SECRET_SCAN_TYPE: { @@ -49,7 +51,7 @@ } -class TablePrinter(BaseTablePrinter): +class TablePrinter(TablePrinterBase): def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: table = self._get_table() if self.scan_type in COLUMN_WIDTHS_CONFIG: @@ -63,7 +65,7 @@ def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: table.set(SCAN_ID_COLUMN, local_scan_result.scan_id) self._enrich_table_with_values(table, detection, document_detections.document) - click.echo(table.get_table().draw()) + self._print_table(table) def _get_table(self) -> Table: table = Table() @@ -74,7 +76,6 @@ def _get_table(self) -> Table: table.add(LINE_NUMBER_COLUMN) table.add(COLUMN_NUMBER_COLUMN) table.add(SCAN_ID_COLUMN) - table.add(REPORT_URL_COLUMN) if self._is_git_repository(): table.add(COMMIT_SHA_COLUMN) @@ -84,6 +85,9 @@ def _get_table(self) -> Table: table.add(VIOLATION_LENGTH_COLUMN) table.add(VIOLATION_COLUMN) + if self.context.obj.get('report'): + table.add(REPORT_URL_COLUMN) + return table def _enrich_table_with_values(self, table: Table, detection: Detection, document: Document) -> None: diff --git a/cycode/cli/printers/base_table_printer.py b/cycode/cli/printers/tables/table_printer_base.py similarity index 82% rename from cycode/cli/printers/base_table_printer.py rename to cycode/cli/printers/tables/table_printer_base.py index f304f967..ab444c58 100644 --- a/cycode/cli/printers/base_table_printer.py +++ b/cycode/cli/printers/tables/table_printer_base.py @@ -4,17 +4,17 @@ import click from cycode.cli.models import CliError, CliResult -from cycode.cli.printers.base_printer import BasePrinter +from cycode.cli.printers.printer_base import PrinterBase from cycode.cli.printers.text_printer import TextPrinter if TYPE_CHECKING: from cycode.cli.models import LocalScanResult + from cycode.cli.printers.tables.table import Table -class BaseTablePrinter(BasePrinter, abc.ABC): +class TablePrinterBase(PrinterBase, abc.ABC): def __init__(self, context: click.Context) -> None: super().__init__(context) - self.context = context self.scan_type: str = context.obj.get('scan_type') self.show_secret: bool = context.obj.get('show_secret', False) @@ -37,3 +37,7 @@ def _is_git_repository(self) -> bool: @abc.abstractmethod def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: raise NotImplementedError + + @staticmethod + def _print_table(table: 'Table') -> None: + click.echo(table.get_table().draw()) diff --git a/cycode/cli/printers/text_printer.py b/cycode/cli/printers/text_printer.py index d06021a3..0390210a 100644 --- a/cycode/cli/printers/text_printer.py +++ b/cycode/cli/printers/text_printer.py @@ -6,14 +6,14 @@ from cycode.cli.config import config from cycode.cli.consts import COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES, SECRET_SCAN_TYPE from cycode.cli.models import CliError, CliResult, Detection, Document, DocumentDetections -from cycode.cli.printers.base_printer import BasePrinter +from cycode.cli.printers.printer_base import PrinterBase from cycode.cli.utils.string_utils import get_position_in_line, obfuscate_text if TYPE_CHECKING: from cycode.cli.models import LocalScanResult -class TextPrinter(BasePrinter): +class TextPrinter(PrinterBase): def __init__(self, context: click.Context) -> None: super().__init__(context) self.scan_type: str = context.obj.get('scan_type') From 6feeb0803e7d115cd045e8d07a29e4fd8028bbeb Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Mon, 24 Jul 2023 15:43:11 +0400 Subject: [PATCH 018/257] CM-25594 - Drop support of the old position of --output option (#148) --- cycode/cli/main.py | 21 +-------------------- tests/cli/test_main.py | 18 ++---------------- 2 files changed, 3 insertions(+), 36 deletions(-) diff --git a/cycode/cli/main.py b/cycode/cli/main.py index 3fd99f7f..2f5c5b04 100644 --- a/cycode/cli/main.py +++ b/cycode/cli/main.py @@ -75,17 +75,6 @@ type=bool, required=False, ) -@click.option( - '--output', - '-o', - default=None, - help=""" - \b - Specify the results output (text/json/table), - the default is text - """, - type=click.Choice(['text', 'json', 'table']), -) @click.option( '--severity-threshold', default=None, @@ -127,7 +116,6 @@ def code_scan( client_id: str, show_secret: bool, soft_fail: bool, - output: str, severity_threshold: str, sca_scan: List[str], monitor: bool, @@ -143,15 +131,8 @@ def code_scan( else: context.obj['soft_fail'] = config['soft_fail'] - context.obj['scan_type'] = scan_type - - # save backward compatability with old style command - if output is not None: - context.obj['output'] = output - if output == 'json': - context.obj['no_progress_meter'] = True - context.obj['client'] = get_cycode_client(client_id, secret, not context.obj['show_secret']) + context.obj['scan_type'] = scan_type context.obj['severity_threshold'] = severity_threshold context.obj['monitor'] = monitor context.obj['report'] = report diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py index 77375920..8c3c6246 100644 --- a/tests/cli/test_main.py +++ b/tests/cli/test_main.py @@ -25,10 +25,7 @@ def _is_json(plain: str) -> bool: @responses.activate @pytest.mark.parametrize('output', ['text', 'json']) -@pytest.mark.parametrize('option_space', ['scan', 'global']) -def test_passing_output_option( - output: str, option_space: str, scan_client: 'ScanClient', api_token_response: responses.Response -) -> None: +def test_passing_output_option(output: str, scan_client: 'ScanClient', api_token_response: responses.Response) -> None: scan_type = 'secret' responses.add(get_zipped_file_scan_response(get_zipped_file_scan_url(scan_type, scan_client))) @@ -37,18 +34,7 @@ def test_passing_output_option( # This raises connection error on the attempt to report scan. # It doesn't perform real request - args = ['scan', '--soft-fail', 'path', str(_PATH_TO_SCAN)] - - if option_space == 'global': - global_args = ['--output', output] - global_args.extend(args) - - args = global_args - elif option_space == 'scan': - # test backward compatability with old style command - args.insert(2, '--output') - args.insert(3, output) - + args = ['--output', output, 'scan', '--soft-fail', 'path', str(_PATH_TO_SCAN)] result = CliRunner().invoke(main_cli, args, env=CLI_ENV_VARS) except_json = output == 'json' From f8149a5c31ea0aab8a8bece13ae794dfeb7847c2 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Mon, 24 Jul 2023 15:53:16 +0400 Subject: [PATCH 019/257] CM-25595 - Drop support of --version option (#147) --- .github/workflows/build_executable.yml | 4 ++-- README.md | 27 +++++++++++++------------- cycode/cli/main.py | 1 - 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 4f0417a8..f3690f11 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -62,7 +62,7 @@ jobs: run: poetry run pyinstaller pyinstaller.spec - name: Test executable - run: ./dist/cycode --version + run: ./dist/cycode version - name: Sign macOS executable if: ${{ startsWith(matrix.os, 'macos') }} @@ -110,7 +110,7 @@ jobs: - name: Test signed executable if: ${{ startsWith(matrix.os, 'macos') }} - run: ./dist/cycode --version + run: ./dist/cycode version - uses: actions/upload-artifact@v3 with: diff --git a/README.md b/README.md index 5ea2bdc6..71b885e1 100644 --- a/README.md +++ b/README.md @@ -213,19 +213,19 @@ repos: The following are the options and commands available with the Cycode CLI application: -| Option | Description | -|--------------------------------------|-------------------------------------------------------------------| -| `-o`, `--output [text\|json\|table]` | Specify the output (`text`/`json`/`table`). The default is `text` | -| `-v`, `--verbose` | Show detailed logs | -| `--version` | Show the version and exit. | -| `--help` | Show options for given command. | - -| Command | Description | -|-------------------------------------|-------------| -| [auth](#use-auth-command) | Authenticates your machine to associate CLI with your Cycode account. | -| [configure](#use-configure-command) | Initial command to authenticate your CLI client with Cycode using client ID and client secret. | -| [ignore](#ingoring-scan-results) | Ignore a specific value, path or rule ID | -| [scan](#running-a-scan) | Scan content for secrets/IaC/SCA/SAST violations. You need to specify which scan type: `ci`/`commit_history`/`path`/`repository`/etc | +| Option | Description | +|--------------------------------------|--------------------------------------------------------------------| +| `-o`, `--output [text\|json\|table]` | Specify the output (`text`/`json`/`table`). The default is `text`. | +| `-v`, `--verbose` | Show detailed logs. | +| `--help` | Show options for given command. | + +| Command | Description | +|-------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------| +| [auth](#use-auth-command) | Authenticates your machine to associate CLI with your Cycode account. | +| [configure](#use-configure-command) | Initial command to authenticate your CLI client with Cycode using client ID and client secret. | +| [ignore](#ingoring-scan-results) | Ignore a specific value, path or rule ID. | +| [scan](#running-a-scan) | Scan content for secrets/IaC/SCA/SAST violations. You need to specify which scan type: `ci`/`commit_history`/`path`/`repository`/etc. | +| version | Show the version and exit. | # Running a Scan @@ -555,6 +555,7 @@ Ignore rules can be added to ignore specific secret values, specific SHA512 valu > :warning: **Warning**
> Adding values to be ignored should be done with careful consideration of the values, paths, and policies to ensure that the scans will pick up true positives. + The following are the options available for the `cycode ignore` command: | Option | Description | diff --git a/cycode/cli/main.py b/cycode/cli/main.py index 2f5c5b04..4263ff02 100644 --- a/cycode/cli/main.py +++ b/cycode/cli/main.py @@ -214,7 +214,6 @@ def version(context: click.Context) -> None: help='Characteristic JSON object that lets servers identify the application', type=str, ) -@click.version_option(__version__, prog_name=PROGRAM_NAME) @click.pass_context def main_cli( context: click.Context, verbose: bool, no_progress_meter: bool, output: str, user_agent: Optional[str] From 62b49071756b4fd2461097f8890859969d97efe7 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 26 Jul 2023 17:49:22 +0400 Subject: [PATCH 020/257] CM-25653 - Improve text messages (#149) --- cycode/cli/auth/auth_command.py | 13 +++-- cycode/cli/code_scanner.py | 22 +++++--- cycode/cli/consts.py | 4 ++ cycode/cli/main.py | 51 ++++++++----------- .../cli/printers/tables/sca_table_printer.py | 2 +- .../user_settings/user_settings_commands.py | 28 +++++----- 6 files changed, 61 insertions(+), 59 deletions(-) diff --git a/cycode/cli/auth/auth_command.py b/cycode/cli/auth/auth_command.py index 3fe037bf..c7853068 100644 --- a/cycode/cli/auth/auth_command.py +++ b/cycode/cli/auth/auth_command.py @@ -12,10 +12,11 @@ @click.group( - invoke_without_command=True, short_help='Authenticates your machine to associate CLI with your cycode account' + invoke_without_command=True, short_help='Authenticate your machine to associate the CLI with your Cycode account.' ) @click.pass_context def authenticate(context: click.Context) -> None: + """Authenticates your machine.""" if context.invoked_subcommand is not None: # if it is a subcommand, do nothing return @@ -32,14 +33,16 @@ def authenticate(context: click.Context) -> None: _handle_exception(context, e) -@authenticate.command(name='check') +@authenticate.command( + name='check', short_help='Checks that your machine is associating the CLI with your Cycode account.' +) @click.pass_context def authorization_check(context: click.Context) -> None: - """Check your machine associating CLI with your cycode account""" + """Validates that your Cycode account has permission to work with the CLI.""" printer = ConsolePrinter(context) - passed_auth_check_res = CliResult(success=True, message='You are authorized') - failed_auth_check_res = CliResult(success=False, message='You are not authorized') + 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() if not client_id or not client_secret: diff --git a/cycode/cli/code_scanner.py b/cycode/cli/code_scanner.py index 15313d45..a7588172 100644 --- a/cycode/cli/code_scanner.py +++ b/cycode/cli/code_scanner.py @@ -51,7 +51,7 @@ start_scan_time = time.time() -@click.command(short_help='Scan git repository including its history') +@click.command(short_help='Scan the git repository including its history.') @click.argument('path', nargs=1, type=click.STRING, required=True) @click.option( '--branch', @@ -72,6 +72,7 @@ def scan_repository(context: click.Context, path: str, branch: str) -> None: raise click.ClickException('Monitor flag is currently supported for SCA scan type only') progress_bar = context.obj['progress_bar'] + progress_bar.start() file_entries = list(get_git_repository_tree_file_entries(path, branch)) progress_bar.set_section_length(ProgressBarSection.PREPARE_LOCAL_FILES, len(file_entries)) @@ -96,7 +97,7 @@ def scan_repository(context: click.Context, path: str, branch: str) -> None: _handle_exception(context, e) -@click.command(short_help='Scan all the commits history in this git repository') +@click.command(short_help='Scan all the commits history in this git repository.') @click.argument('path', nargs=1, type=click.STRING, required=True) @click.option( '--commit_range', @@ -119,7 +120,9 @@ def scan_commit_range( context: click.Context, path: str, commit_range: str, max_commits_count: Optional[int] = None ) -> None: scan_type = context.obj['scan_type'] + progress_bar = context.obj['progress_bar'] + progress_bar.start() if scan_type not in consts.COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES: raise click.ClickException(f'Commit range scanning for {str.upper(scan_type)} is not supported') @@ -185,13 +188,14 @@ def scan_ci(context: click.Context) -> None: scan_commit_range(context, path=os.getcwd(), commit_range=get_commit_range()) -@click.command(short_help='Scan the files in the path supplied in the command') +@click.command(short_help='Scan the files in the path provided in the command.') @click.argument('path', nargs=1, type=click.STRING, required=True) @click.pass_context def scan_path(context: click.Context, path: str) -> None: - logger.debug('Starting path scan process, %s', {'path': path}) - progress_bar = context.obj['progress_bar'] + progress_bar.start() + + logger.debug('Starting path scan process, %s', {'path': path}) all_files_to_scan = get_relevant_files_in_path(path=path, exclude_patterns=['**/.git/**', '**/.cycode/**']) @@ -218,12 +222,14 @@ def scan_path(context: click.Context, path: str) -> None: scan_disk_files(context, path, relevant_files_to_scan) -@click.command(short_help='Use this command to scan the content that was not committed yet') +@click.command(short_help='Use this command to scan any content that was not committed yet.') @click.argument('ignored_args', nargs=-1, type=click.UNPROCESSED) @click.pass_context def pre_commit_scan(context: click.Context, ignored_args: List[str]) -> None: scan_type = context.obj['scan_type'] + progress_bar = context.obj['progress_bar'] + progress_bar.start() if scan_type == consts.SCA_SCAN_TYPE: scan_sca_pre_commit(context) @@ -242,7 +248,7 @@ def pre_commit_scan(context: click.Context, ignored_args: List[str]) -> None: scan_documents(context, documents_to_scan, is_git_diff=True) -@click.command(short_help='Use this command to scan commits on the server side before pushing them to the repository') +@click.command(short_help='Use this command to scan commits on the server side before pushing them to the repository.') @click.argument('ignored_args', nargs=-1, type=click.UNPROCESSED) @click.pass_context def pre_receive_scan(context: click.Context, ignored_args: List[str]) -> None: @@ -1160,7 +1166,7 @@ def _handle_exception(context: click.Context, e: Exception, *, return_exception: soft_fail=False, code='invalid_git_error', message='The path you supplied does not correlate to a git repository. ' - 'Should you still wish to scan this path, use: `cycode scan path `', + 'If you still wish to scan this path, use: `cycode scan path `', ), } diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 6d710b1b..76570fde 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -1,4 +1,8 @@ PROGRAM_NAME = 'cycode' +CLI_CONTEXT_SETTINGS = { + 'terminal_width': 10**9, + 'max_content_width': 10**9, +} PRE_COMMIT_COMMAND_SCAN_TYPE = 'pre_commit' PRE_RECEIVE_COMMAND_SCAN_TYPE = 'pre_receive' diff --git a/cycode/cli/main.py b/cycode/cli/main.py index 4263ff02..821251ad 100644 --- a/cycode/cli/main.py +++ b/cycode/cli/main.py @@ -9,7 +9,7 @@ from cycode.cli import code_scanner from cycode.cli.auth.auth_command import authenticate from cycode.cli.config import config -from cycode.cli.consts import ISSUE_DETECTED_STATUS_CODE, NO_ISSUES_STATUS_CODE, PROGRAM_NAME +from cycode.cli.consts import CLI_CONTEXT_SETTINGS, ISSUE_DETECTED_STATUS_CODE, NO_ISSUES_STATUS_CODE, PROGRAM_NAME from cycode.cli.models import Severity from cycode.cli.user_settings.configuration_manager import ConfigurationManager from cycode.cli.user_settings.credentials_manager import CredentialsManager @@ -25,8 +25,6 @@ if TYPE_CHECKING: from cycode.cyclient.scan_client import ScanClient -CONTEXT = {} - @click.group( commands={ @@ -36,56 +34,52 @@ 'pre_commit': code_scanner.pre_commit_scan, 'pre_receive': code_scanner.pre_receive_scan, }, - short_help='Scan content for secrets/IaC/sca/SAST violations. ' - 'You need to specify which scan type: ci/commit_history/path/repository/etc', + short_help='Scan the content for Secrets/IaC/SCA/SAST violations. ' + 'You`ll need to specify which scan type to perform: ci/commit_history/path/repository/etc.', ) @click.option( '--scan-type', '-t', default='secret', - help=""" - \b - Specify the scan you wish to execute (secret/iac/sca), - the default is secret - """, + help='Specify the type of scan you wish to execute (the default is Secrets)', type=click.Choice(config['scans']['supported_scans']), ) @click.option( '--secret', default=None, - help='Specify a Cycode client secret for this specific scan execution', + help='Specify a Cycode client secret for this specific scan execution.', type=str, required=False, ) @click.option( '--client-id', default=None, - help='Specify a Cycode client ID for this specific scan execution', + help='Specify a Cycode client ID for this specific scan execution.', type=str, required=False, ) @click.option( - '--show-secret', is_flag=True, default=False, help='Show secrets in plain text', type=bool, required=False + '--show-secret', is_flag=True, default=False, help='Show Secrets in plain text.', type=bool, required=False ) @click.option( '--soft-fail', is_flag=True, default=False, - help='Run scan without failing, always return a non-error status code', + help='Run the scan without failing; always return a non-error status code.', type=bool, required=False, ) @click.option( '--severity-threshold', default=None, - help='Show only violations at the specified level or higher (supported for SCA scan type only).', + help='Show violations only for the specified level or higher (supported for SCA scan types only).', type=click.Choice([e.name for e in Severity]), required=False, ) @click.option( '--sca-scan', default=None, - help='Specify the sca scan you wish to execute (package-vulnerabilities/license-compliance), the default is both', + help='Specify the type of SCA scan you wish to execute (the default is both).', multiple=True, type=click.Choice(config['scans']['supported_sca_scans']), ) @@ -93,9 +87,7 @@ '--monitor', is_flag=True, default=False, - help="When specified, the scan results will be recorded in the knowledge graph. " - "Please note that when working in 'monitor' mode, the knowledge graph " - "will not be updated as a result of SCM events (Push, Repo creation).(supported for SCA scan type only).", + help='Used for SCA scan types only; when specified, the scan results are recorded in the Discovery module.', type=bool, required=False, ) @@ -103,8 +95,7 @@ '--report', is_flag=True, default=False, - help='When specified, a violations report will be generated. ' - 'A URL link to the report will be printed as an output to the command execution', + help='When specified, generates a violations report. A link to the report will be displayed in the console output.', type=bool, required=False, ) @@ -121,6 +112,7 @@ def code_scan( monitor: bool, report: bool, ) -> int: + """Scans for Secrets, IaC, SCA or SAST violations.""" if show_secret: context.obj['show_secret'] = show_secret else: @@ -139,9 +131,6 @@ def code_scan( _sca_scan_to_context(context, sca_scan) - context.obj['progress_bar'] = get_progress_bar(hidden=context.obj['no_progress_meter']) - context.obj['progress_bar'].start() - return 1 @@ -162,7 +151,7 @@ def finalize(context: click.Context, *_, **__) -> None: sys.exit(exit_code) -@click.command(short_help='Show the version and exit') +@click.command(short_help='Show the CLI version and exit.') @click.pass_context def version(context: click.Context) -> None: output = context.obj['output'] @@ -186,32 +175,32 @@ def version(context: click.Context) -> None: 'auth': authenticate, 'version': version, }, - context_settings=CONTEXT, + context_settings=CLI_CONTEXT_SETTINGS, ) @click.option( '--verbose', '-v', is_flag=True, default=False, - help='Show detailed logs', + help='Show detailed logs.', ) @click.option( '--no-progress-meter', is_flag=True, default=False, - help='Do not show the progress meter', + help='Do not show the progress meter.', ) @click.option( '--output', '-o', default='text', - help='Specify the output (text/json/table), the default is text', + help='Specify the output type (the default is text).', type=click.Choice(['text', 'json', 'table']), ) @click.option( '--user-agent', default=None, - help='Characteristic JSON object that lets servers identify the application', + help='Characteristic JSON object that lets servers identify the application.', type=str, ) @click.pass_context @@ -232,7 +221,7 @@ def main_cli( if output == 'json': no_progress_meter = True - context.obj['no_progress_meter'] = no_progress_meter + context.obj['progress_bar'] = get_progress_bar(hidden=no_progress_meter) if user_agent: user_agent_option = UserAgentOptionScheme().loads(user_agent) diff --git a/cycode/cli/printers/tables/sca_table_printer.py b/cycode/cli/printers/tables/sca_table_printer.py index da0ac8ac..92247d41 100644 --- a/cycode/cli/printers/tables/sca_table_printer.py +++ b/cycode/cli/printers/tables/sca_table_printer.py @@ -58,7 +58,7 @@ def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: @staticmethod def _get_title(policy_id: str) -> str: if policy_id == PACKAGE_VULNERABILITY_POLICY_ID: - return 'Dependencies Vulnerabilities' + return 'Dependency Vulnerabilities' if policy_id == LICENSE_COMPLIANCE_POLICY_ID: return 'License Compliance' diff --git a/cycode/cli/user_settings/user_settings_commands.py b/cycode/cli/user_settings/user_settings_commands.py index 500b13db..9629de1e 100644 --- a/cycode/cli/user_settings/user_settings_commands.py +++ b/cycode/cli/user_settings/user_settings_commands.py @@ -22,9 +22,10 @@ @click.command( - short_help='Initial command to authenticate your CLI client with Cycode using client ID and client secret' + short_help='Initial command to authenticate your CLI client with Cycode using a client ID and client secret.' ) def set_credentials() -> None: + """Authenticates your CLI client with Cycode manually by using a client ID and client secret.""" click.echo(f'Update credentials in file ({credentials_manager.get_filename()})') current_client_id, current_client_secret = credentials_manager.get_credentials_from_file() client_id = _get_client_id_input(current_client_id) @@ -37,40 +38,39 @@ def set_credentials() -> None: click.echo(_get_credentials_update_result_message()) -@click.command() +@click.command(short_help='Ignores a specific value, path or rule ID.') @click.option( - '--by-value', type=click.STRING, required=False, help='Ignore a specific value while scanning for secrets' + '--by-value', type=click.STRING, required=False, help='Ignore a specific value while scanning for Secrets.' ) @click.option( '--by-sha', type=click.STRING, required=False, - help='Ignore a specific SHA512 representation of a string while scanning for secrets', + help='Ignore a specific SHA512 representation of a string while scanning for Secrets.', ) @click.option( - '--by-path', type=click.STRING, required=False, help='Avoid scanning a specific path. Need to specify scan type ' + '--by-path', + type=click.STRING, + required=False, + help='Avoid scanning a specific path. You`ll need to specify the scan type.', ) @click.option( '--by-rule', type=click.STRING, required=False, - help='Ignore scanning a specific secret rule ID/IaC rule ID. Need to specify scan type.', + help='Ignore scanning a specific secret rule ID or IaC rule ID. You`ll to specify the scan type.', ) @click.option( '--by-package', type=click.STRING, required=False, - help='Ignore scanning a specific package version while running SCA scan. expected pattern - name@version', + help='Ignore scanning a specific package version while running an SCA scan. Expected pattern: name@version.', ) @click.option( '--scan-type', '-t', default='secret', - help=""" - \b - Specify the scan you wish to execute (secrets/iac), - the default is secrets - """, + help='Specify the type of scan you wish to execute (the default is Secrets).', type=click.Choice(config['scans']['supported_scans']), required=False, ) @@ -81,12 +81,12 @@ def set_credentials() -> None: is_flag=True, default=False, required=False, - help='Add an ignore rule and update it in the global .cycode config file', + help='Add an ignore rule to the global CLI config.', ) def add_exclusions( by_value: str, by_sha: str, by_path: str, by_rule: str, by_package: str, scan_type: str, is_global: bool ) -> None: - """Ignore a specific value, path or rule ID""" + """Ignores a specific value, path or rule ID.""" 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') From d03b42f3f61f603b9e433fe79ca9069d1c328e1e Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Thu, 27 Jul 2023 14:16:54 +0400 Subject: [PATCH 021/257] CM-25728 - Fix relative paths; fix validations of paths (#150) --- cycode/cli/code_scanner.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cycode/cli/code_scanner.py b/cycode/cli/code_scanner.py index a7588172..3d55b21f 100644 --- a/cycode/cli/code_scanner.py +++ b/cycode/cli/code_scanner.py @@ -52,7 +52,7 @@ @click.command(short_help='Scan the git repository including its history.') -@click.argument('path', nargs=1, type=click.STRING, required=True) +@click.argument('path', nargs=1, type=click.Path(exists=True, resolve_path=True), required=True) @click.option( '--branch', '-b', @@ -98,7 +98,7 @@ def scan_repository(context: click.Context, path: str, branch: str) -> None: @click.command(short_help='Scan all the commits history in this git repository.') -@click.argument('path', nargs=1, type=click.STRING, required=True) +@click.argument('path', nargs=1, type=click.Path(exists=True, resolve_path=True), required=True) @click.option( '--commit_range', '-r', @@ -189,7 +189,7 @@ def scan_ci(context: click.Context) -> None: @click.command(short_help='Scan the files in the path provided in the command.') -@click.argument('path', nargs=1, type=click.STRING, required=True) +@click.argument('path', nargs=1, type=click.Path(exists=True, resolve_path=True), required=True) @click.pass_context def scan_path(context: click.Context, path: str) -> None: progress_bar = context.obj['progress_bar'] From d0b46d515415307694b9b170e37d65796e911786 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Fri, 28 Jul 2023 21:20:04 +0400 Subject: [PATCH 022/257] CM-25543 - Implement proper handling of errors in printers (#151) --- cycode/cli/code_scanner.py | 39 ++++++++----------- cycode/cli/printers/console_printer.py | 8 ++-- cycode/cli/printers/json_printer.py | 20 +++++++--- cycode/cli/printers/printer_base.py | 6 ++- .../cli/printers/tables/table_printer_base.py | 23 +++++++++-- cycode/cli/printers/text_printer.py | 20 ++++++++-- cycode/cli/utils/progress_bar.py | 2 + 7 files changed, 78 insertions(+), 40 deletions(-) diff --git a/cycode/cli/code_scanner.py b/cycode/cli/code_scanner.py index 3d55b21f..7c8e1987 100644 --- a/cycode/cli/code_scanner.py +++ b/cycode/cli/code_scanner.py @@ -90,7 +90,7 @@ def scan_repository(context: click.Context, path: str, branch: str) -> None: perform_pre_scan_documents_actions(context, scan_type, documents_to_scan, is_git_diff=False) logger.debug('Found all relevant files for scanning %s', {'path': path, 'branch': branch}) - return scan_documents( + scan_documents( context, documents_to_scan, is_git_diff=False, scan_parameters=get_scan_parameters(context, path) ) except Exception as e: @@ -420,6 +420,16 @@ def scan_documents( ) -> None: progress_bar = context.obj['progress_bar'] + if not documents_to_scan: + progress_bar.stop() + ConsolePrinter(context).print_error( + CliError( + code='no_relevant_files', + message='Error: The scan could not be completed - relevant files to scan are not found.', + ) + ) + return + scan_batch_thread_func = _get_scan_documents_thread_func(context, is_git_diff, is_commit_range, scan_parameters) errors, local_scan_results = run_parallel_batched_scan( scan_batch_thread_func, documents_to_scan, progress_bar=progress_bar @@ -430,25 +440,7 @@ def scan_documents( progress_bar.stop() set_issue_detected_by_scan_results(context, local_scan_results) - print_results(context, local_scan_results) - - if not errors: - return - - if context.obj['output'] == 'json': - # TODO(MarshalX): we can't just print JSON formatted errors here - # because we should return only one root json structure per scan - # could be added later to "print_results" function if we wish to display detailed errors in UI - return - - click.secho( - 'Unfortunately, Cycode was unable to complete the full scan. ' - 'Please note that not all results may be available:', - fg='red', - ) - for scan_id, error in errors.items(): - click.echo(f'- {scan_id}: ', nl=False) - ConsolePrinter(context).print_error(error) + print_results(context, local_scan_results, errors) def scan_commit_range_documents( @@ -506,6 +498,7 @@ def scan_commit_range_documents( progress_bar.update(ProgressBarSection.GENERATE_REPORT) progress_bar.stop() + # errors will be handled with try-except block; printing will not occur on errors print_results(context, [local_scan_result]) scan_completed = True @@ -693,9 +686,11 @@ def print_debug_scan_details(scan_details_response: 'ScanDetailsResponse') -> No logger.debug(f'Scan message: {scan_details_response.message}') -def print_results(context: click.Context, local_scan_results: List[LocalScanResult]) -> None: +def print_results( + context: click.Context, local_scan_results: List[LocalScanResult], errors: Optional[Dict[str, 'CliError']] = None +) -> None: printer = ConsolePrinter(context) - printer.print_scan_results(local_scan_results) + printer.print_scan_results(local_scan_results, errors) def get_document_detections( diff --git a/cycode/cli/printers/console_printer.py b/cycode/cli/printers/console_printer.py index f0bccadf..d9ae56df 100644 --- a/cycode/cli/printers/console_printer.py +++ b/cycode/cli/printers/console_printer.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, ClassVar, Dict, List +from typing import TYPE_CHECKING, ClassVar, Dict, List, Optional import click @@ -33,9 +33,11 @@ def __init__(self, context: click.Context) -> None: if self._printer_class is None: raise CycodeError(f'"{self.output_type}" output type is not supported.') - def print_scan_results(self, local_scan_results: List['LocalScanResult']) -> None: + def print_scan_results( + self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None + ) -> None: printer = self._get_scan_printer() - printer.print_scan_results(local_scan_results) + printer.print_scan_results(local_scan_results, errors) def _get_scan_printer(self) -> 'PrinterBase': printer_class = self._printer_class diff --git a/cycode/cli/printers/json_printer.py b/cycode/cli/printers/json_printer.py index 2f048ae7..89b903ad 100644 --- a/cycode/cli/printers/json_printer.py +++ b/cycode/cli/printers/json_printer.py @@ -1,5 +1,5 @@ import json -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING, Dict, List, Optional import click @@ -15,14 +15,16 @@ class JsonPrinter(PrinterBase): def print_result(self, result: CliResult) -> None: result = {'result': result.success, 'message': result.message} - click.secho(self.get_data_json(result)) + click.echo(self.get_data_json(result)) def print_error(self, error: CliError) -> None: result = {'error': error.code, 'message': error.message} - click.secho(self.get_data_json(result)) + click.echo(self.get_data_json(result)) - def print_scan_results(self, local_scan_results: List['LocalScanResult']) -> None: + def print_scan_results( + self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None + ) -> None: detections = [] for local_scan_result in local_scan_results: for document_detections in local_scan_result.document_detections: @@ -30,12 +32,18 @@ def print_scan_results(self, local_scan_results: List['LocalScanResult']) -> Non detections_dict = DetectionSchema(many=True).dump(detections) - click.secho(self._get_json_scan_result(detections_dict)) + inlined_errors = [] + if errors: + # FIXME(MarshalX): we don't care about scan IDs in JSON output due to clumsy JSON root structure + inlined_errors = [err._asdict() for err in errors.values()] - def _get_json_scan_result(self, detections: dict) -> str: + click.echo(self._get_json_scan_result(detections_dict, inlined_errors)) + + def _get_json_scan_result(self, detections: dict, errors: List[dict]) -> str: result = { 'scan_id': 'DEPRECATED', # FIXME(MarshalX): we need change JSON struct to support multiple scan results 'detections': detections, + 'errors': errors, } return self.get_data_json(result) diff --git a/cycode/cli/printers/printer_base.py b/cycode/cli/printers/printer_base.py index 69802e54..e1fbfa51 100644 --- a/cycode/cli/printers/printer_base.py +++ b/cycode/cli/printers/printer_base.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING, Dict, List, Optional import click @@ -18,7 +18,9 @@ def __init__(self, context: click.Context) -> None: self.context = context @abstractmethod - def print_scan_results(self, local_scan_results: List['LocalScanResult']) -> None: + def print_scan_results( + self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None + ) -> None: pass @abstractmethod diff --git a/cycode/cli/printers/tables/table_printer_base.py b/cycode/cli/printers/tables/table_printer_base.py index ab444c58..10a94e55 100644 --- a/cycode/cli/printers/tables/table_printer_base.py +++ b/cycode/cli/printers/tables/table_printer_base.py @@ -1,5 +1,5 @@ import abc -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING, Dict, List, Optional import click @@ -24,13 +24,27 @@ def print_result(self, result: CliResult) -> None: def print_error(self, error: CliError) -> None: TextPrinter(self.context).print_error(error) - def print_scan_results(self, local_scan_results: List['LocalScanResult']) -> None: - if all(result.issue_detected == 0 for result in local_scan_results): + def print_scan_results( + self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None + ) -> None: + if not errors and all(result.issue_detected == 0 for result in local_scan_results): click.secho('Good job! No issues were found!!! 👏👏👏', fg=self.GREEN_COLOR_NAME) return self._print_results(local_scan_results) + if not errors: + return + + click.secho( + 'Unfortunately, Cycode was unable to complete the full scan. ' + 'Please note that not all results may be available:', + fg='red', + ) + for scan_id, error in errors.items(): + click.echo(f'- {scan_id}: ', nl=False) + self.print_error(error) + def _is_git_repository(self) -> bool: return self.context.obj.get('remote_url') is not None @@ -40,4 +54,5 @@ def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: @staticmethod def _print_table(table: 'Table') -> None: - click.echo(table.get_table().draw()) + if table.get_rows(): + click.echo(table.get_table().draw()) diff --git a/cycode/cli/printers/text_printer.py b/cycode/cli/printers/text_printer.py index 0390210a..821e755f 100644 --- a/cycode/cli/printers/text_printer.py +++ b/cycode/cli/printers/text_printer.py @@ -1,5 +1,5 @@ import math -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING, Dict, List, Optional import click @@ -30,8 +30,10 @@ def print_result(self, result: CliResult) -> None: def print_error(self, error: CliError) -> None: click.secho(error.message, fg=self.RED_COLOR_NAME) - def print_scan_results(self, local_scan_results: List['LocalScanResult']) -> None: - if all(result.issue_detected == 0 for result in local_scan_results): + def print_scan_results( + self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None + ) -> None: + if not errors and all(result.issue_detected == 0 for result in local_scan_results): click.secho('Good job! No issues were found!!! 👏👏👏', fg=self.GREEN_COLOR_NAME) return @@ -41,6 +43,18 @@ def print_scan_results(self, local_scan_results: List['LocalScanResult']) -> Non document_detections, local_scan_result.scan_id, local_scan_result.report_url ) + if not errors: + return + + click.secho( + 'Unfortunately, Cycode was unable to complete the full scan. ' + 'Please note that not all results may be available:', + fg='red', + ) + for scan_id, error in errors.items(): + click.echo(f'- {scan_id}: ', nl=False) + self.print_error(error) + def _print_document_detections( self, document_detections: DocumentDetections, scan_id: str, report_url: Optional[str] ) -> None: diff --git a/cycode/cli/utils/progress_bar.py b/cycode/cli/utils/progress_bar.py index d22abf84..083d0715 100644 --- a/cycode/cli/utils/progress_bar.py +++ b/cycode/cli/utils/progress_bar.py @@ -146,6 +146,8 @@ def set_section_length(self, section: 'ProgressBarSection', length: int) -> None if length == 0: self._skip_section(section) + else: + self._maybe_update_current_section() def _skip_section(self, section: 'ProgressBarSection') -> None: self._progress_bar.update(_get_section_length(section)) From 78e8763a2e508798a557744e53b70bf7ecb568f0 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Fri, 28 Jul 2023 22:06:57 +0400 Subject: [PATCH 023/257] CM-24629 - Lock urllib3 v1 (#152) update certifi to fix CVE-2023-37920 --- poetry.lock | 23 +++++++++++------------ pyproject.toml | 1 + 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/poetry.lock b/poetry.lock index b5584637..e4137a58 100644 --- a/poetry.lock +++ b/poetry.lock @@ -91,13 +91,13 @@ uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "certifi" -version = "2023.5.7" +version = "2023.7.22" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2023.5.7-py3-none-any.whl", hash = "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716"}, - {file = "certifi-2023.5.7.tar.gz", hash = "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7"}, + {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"}, + {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, ] [[package]] @@ -872,20 +872,19 @@ files = [ [[package]] name = "urllib3" -version = "2.0.2" +version = "1.26.16" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false -python-versions = ">=3.7" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" files = [ - {file = "urllib3-2.0.2-py3-none-any.whl", hash = "sha256:d055c2f9d38dc53c808f6fdc8eab7360b6fdbbde02340ed25cfbcd817c62469e"}, - {file = "urllib3-2.0.2.tar.gz", hash = "sha256:61717a1095d7e155cdb737ac7bb2f4324a858a1e2e6466f6d03ff630ca68d3cc"}, + {file = "urllib3-1.26.16-py2.py3-none-any.whl", hash = "sha256:8d36afa7616d8ab714608411b4a3b13e58f463aee519024578e062e141dce20f"}, + {file = "urllib3-1.26.16.tar.gz", hash = "sha256:8f135f6502756bde6b2a9b28989df5fbe87c9970cecaa69041edcce7f0589b14"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] -secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "zipp" @@ -905,4 +904,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = ">=3.7,<3.12" -content-hash = "5cb535c853d7233ac226c8ea2e50ca05ee7243bfb81ce72f76e93be3f56d1e7f" +content-hash = "ca732947ba4a2d16d4697a43b8c81c77cf2f9b73892ba7f8fa80c43703ec8b0b" diff --git a/pyproject.toml b/pyproject.toml index 4c093047..93fa2516 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ arrow = ">=0.17.0,<0.18.0" binaryornot = ">=0.4.4,<0.5.0" texttable = ">=1.6.7,<1.7.0" requests = ">=2.24,<3.0" +urllib3 = "1.26.16" # lock v1 to avoid issues with openssl and old Python versions (<3.9.11) on macOS [tool.poetry.group.test.dependencies] mock = ">=4.0.3,<4.1.0" From 4c04a32df27573f6c791f7b8f627e88c3776016b Mon Sep 17 00:00:00 2001 From: EfratIsrael <139685962+EfratIsrael@users.noreply.github.com> Date: Mon, 7 Aug 2023 13:19:49 +0300 Subject: [PATCH 024/257] CM-25773 - Support TF Plan scans (#153) * CM-25773- initial commit * lint fix * remove new line * add try-except * change list to List * key error exception handler parser test * prettier * encoding * fix path * lint * review fixing * renaming + typo fixing * remove redundant json load check * change soft fail * add json.loads wrapper * lint * remove constructor * refactor iac parsing * handle null after + add test * Fix error handling * Fix replacement of files * fix iac doc manipulation * revert iac manipultaion outside pre scan doc * adding tests for different plans + handle delete action list + update readme * fix readme * fix readme * fixing * pr fixing * pr fixing * lint * test fix * add test for generate document * lint * update test * typo fix * move \n --------- Co-authored-by: Ilya Siamionau --- README.md | 31 + cycode/cli/code_scanner.py | 67 +- cycode/cli/exceptions/custom_exceptions.py | 9 + cycode/cli/helpers/tf_content_generator.py | 49 + cycode/cli/models.py | 12 + cycode/cli/utils/path_utils.py | 17 + tests/cli/helpers/__init__.py | 0 .../cli/helpers/test_tf_content_generator.py | 16 + tests/cli/test_code_scanner.py | 62 +- .../tfplan-create-example/tf_content.txt | 113 ++ .../tfplan-create-example/tfplan.json | 1072 +++++++++++ .../tfplan-destroy-example/tf_content.txt | 0 .../tfplan-destroy-example/tfplan.json | 1082 +++++++++++ .../tfplan-false-var/tf_content.txt | 14 + .../tfplan-false-var/tfplan.json | 218 +++ .../tfplan-no-op-example/tf_content.txt | 122 ++ .../tfplan-no-op-example/tfplan.json | 1634 +++++++++++++++++ .../tfplan-null-example/tf_content.txt | 4 + .../tfplan-null-example/tfplan.json | 75 + .../tfplan-update-example/tf_content.txt | 34 + .../tfplan-update-example/tfplan.json | 736 ++++++++ 21 files changed, 5355 insertions(+), 12 deletions(-) create mode 100644 cycode/cli/helpers/tf_content_generator.py create mode 100644 tests/cli/helpers/__init__.py create mode 100644 tests/cli/helpers/test_tf_content_generator.py create mode 100644 tests/test_files/tf_content_generator_files/tfplan-create-example/tf_content.txt create mode 100644 tests/test_files/tf_content_generator_files/tfplan-create-example/tfplan.json create mode 100644 tests/test_files/tf_content_generator_files/tfplan-destroy-example/tf_content.txt create mode 100644 tests/test_files/tf_content_generator_files/tfplan-destroy-example/tfplan.json create mode 100644 tests/test_files/tf_content_generator_files/tfplan-false-var/tf_content.txt create mode 100644 tests/test_files/tf_content_generator_files/tfplan-false-var/tfplan.json create mode 100644 tests/test_files/tf_content_generator_files/tfplan-no-op-example/tf_content.txt create mode 100644 tests/test_files/tf_content_generator_files/tfplan-no-op-example/tfplan.json create mode 100644 tests/test_files/tf_content_generator_files/tfplan-null-example/tf_content.txt create mode 100644 tests/test_files/tf_content_generator_files/tfplan-null-example/tfplan.json create mode 100644 tests/test_files/tf_content_generator_files/tfplan-update-example/tf_content.txt create mode 100644 tests/test_files/tf_content_generator_files/tfplan-update-example/tfplan.json diff --git a/README.md b/README.md index 71b885e1..4e3da50a 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ This guide will guide you through both installation and usage. 1. [License Compliance Option](#license-compliance-option) 2. [Severity Threshold](#severity-threshold) 5. [Path Scan](#path-scan) + 1. [Terraform Plan Scan](#terraform-plan-scan) 6. [Commit History Scan](#commit-history-scan) 1. [Commit Range Option](#commit-range-option) 7. [Pre-Commit Scan](#pre-commit-scan) @@ -421,6 +422,36 @@ For example, consider a scenario in which you want to scan the directory located `cycode scan path ~/home/git/codebase` + +### Terraform Plan Scan + +Cycode CLI supports Terraform plan scanning (supporting Terraform 0.12 and later) + +Terraform plan file must be in JSON format (having `.json` extension) + + +_How to generate a Terraform plan from Terraform configuration file?_ + +1. Initialize a working directory that contains Terraform configuration file: + + `terraform init` + + +2. Create Terraform execution plan and save the binary output: + + `terraform plan -out={tfplan_output}` + + +3. Convert the binary output file into readable JSON: + + `terraform show -json {tfplan_output} > {tfplan}.json` + + +4. Scan your `{tfplan}.json` with Cycode CLI: + + `cycode scan -t iac path ~/PATH/TO/YOUR/{tfplan}.json` + + ## Commit History Scan A commit history scan is limited to a local repository’s previous commits, focused on finding any secrets within the commit history, instead of examining the repository’s current state. diff --git a/cycode/cli/code_scanner.py b/cycode/cli/code_scanner.py index 7c8e1987..978b4240 100644 --- a/cycode/cli/code_scanner.py +++ b/cycode/cli/code_scanner.py @@ -16,18 +16,20 @@ from cycode.cli.ci_integrations import get_commit_range from cycode.cli.config import configuration_manager from cycode.cli.exceptions import custom_exceptions -from cycode.cli.helpers import sca_code_scanner +from cycode.cli.helpers import sca_code_scanner, tf_content_generator from cycode.cli.models import CliError, CliErrors, Document, DocumentDetections, LocalScanResult, Severity from cycode.cli.printers import ConsolePrinter from cycode.cli.user_settings.config_file_manager import ConfigFileManager from cycode.cli.utils import scan_utils from cycode.cli.utils.path_utils import ( + change_filename_extension, get_file_content, get_file_size, get_path_by_os, get_relevant_files_in_path, is_binary_file, is_sub_path, + load_json, ) from cycode.cli.utils.progress_bar import ProgressBarSection from cycode.cli.utils.progress_bar import logger as progress_bar_logger @@ -328,18 +330,22 @@ def scan_disk_files(context: click.Context, path: str, files_to_scan: List[str]) is_git_diff = False - documents: List[Document] = [] - for file in files_to_scan: - progress_bar.update(ProgressBarSection.PREPARE_LOCAL_FILES) + try: + documents: List[Document] = [] + for file in files_to_scan: + progress_bar.update(ProgressBarSection.PREPARE_LOCAL_FILES) - content = get_file_content(file) - if not content: - continue + content = get_file_content(file) + if not content: + continue + + documents.append(_generate_document(file, scan_type, content, is_git_diff)) - documents.append(Document(file, content, is_git_diff)) + perform_pre_scan_documents_actions(context, scan_type, documents, is_git_diff) + scan_documents(context, documents, is_git_diff=is_git_diff, scan_parameters=scan_parameters) - perform_pre_scan_documents_actions(context, scan_type, documents, is_git_diff) - scan_documents(context, documents, is_git_diff=is_git_diff, scan_parameters=scan_parameters) + except Exception as e: + _handle_exception(context, e) def set_issue_detected_by_scan_results(context: click.Context, scan_results: List[LocalScanResult]) -> None: @@ -574,7 +580,7 @@ def perform_pre_scan_documents_actions( context: click.Context, scan_type: str, documents_to_scan: List[Document], is_git_diff: bool = False ) -> None: if scan_type == consts.SCA_SCAN_TYPE: - logger.debug('Perform pre scan document actions') + logger.debug('Perform pre scan document add_dependencies_tree_document action') sca_code_scanner.add_dependencies_tree_document(context, documents_to_scan, is_git_diff) @@ -1099,6 +1105,37 @@ def _is_file_extension_supported(scan_type: str, filename: str) -> bool: return not filename.endswith(consts.SECRET_SCAN_FILE_EXTENSIONS_TO_IGNORE) +def _generate_document(file: str, scan_type: str, content: str, is_git_diff: bool) -> Document: + if _is_iac(scan_type) and _is_tfplan_file(file, content): + return _handle_tfplan_file(file, content, is_git_diff) + return Document(file, content, is_git_diff) + + +def _handle_tfplan_file(file: str, content: str, is_git_diff: bool) -> Document: + document_name = _generate_tfplan_document_name(file) + tf_content = tf_content_generator.generate_tf_content_from_tfplan(file, content) + return Document(document_name, tf_content, is_git_diff) + + +def _generate_tfplan_document_name(path: str) -> str: + document_name = change_filename_extension(path, 'tf') + timestamp = int(time.time()) + return f'{timestamp}-{document_name}' + + +def _is_iac(scan_type: str) -> bool: + return scan_type == consts.INFRA_CONFIGURATION_SCAN_TYPE + + +def _is_tfplan_file(file: str, content: str) -> bool: + if not file.endswith('.json'): + return False + tf_plan = load_json(content) + if not isinstance(tf_plan, dict): + return False + return 'resource_changes' in tf_plan + + def _does_file_exceed_max_size_limit(filename: str) -> bool: return get_file_size(filename) > consts.FILE_MAX_SIZE_LIMIT_IN_BYTES @@ -1157,6 +1194,14 @@ def _handle_exception(context: click.Context, e: Exception, *, return_exception: 'Please try ignoring irrelevant paths using the `cycode ignore --by-path` command ' 'and execute the scan again', ), + custom_exceptions.TfplanKeyError: CliError( + soft_fail=True, + code='key_error', + message=f'\n{e!s}\n' + 'A crucial field is missing in your terraform plan file. ' + 'Please make sure that your file is well formed ' + 'and execute the scan again', + ), InvalidGitRepositoryError: CliError( soft_fail=False, code='invalid_git_error', diff --git a/cycode/cli/exceptions/custom_exceptions.py b/cycode/cli/exceptions/custom_exceptions.py index 4806e17c..ea98a0aa 100644 --- a/cycode/cli/exceptions/custom_exceptions.py +++ b/cycode/cli/exceptions/custom_exceptions.py @@ -55,3 +55,12 @@ def __init__(self, error_message: str) -> None: def __str__(self) -> str: return f'Something went wrong during the authentication process, error message: {self.error_message}' + + +class TfplanKeyError(CycodeError): + def __init__(self, file_path: str) -> None: + self.file_path = file_path + super().__init__() + + def __str__(self) -> str: + return f'Error occurred while parsing terraform plan file. Path: {self.file_path}' diff --git a/cycode/cli/helpers/tf_content_generator.py b/cycode/cli/helpers/tf_content_generator.py new file mode 100644 index 00000000..7594a96f --- /dev/null +++ b/cycode/cli/helpers/tf_content_generator.py @@ -0,0 +1,49 @@ +import json +from typing import List + +from cycode.cli.exceptions.custom_exceptions import TfplanKeyError +from cycode.cli.models import ResourceChange +from cycode.cli.utils.path_utils import load_json + +ACTIONS_TO_OMIT_RESOURCE = ['delete'] + + +def generate_tf_content_from_tfplan(filename: str, tfplan: str) -> str: + planned_resources = _extract_resources(tfplan, filename) + return _generate_tf_content(planned_resources) + + +def _generate_tf_content(resource_changes: List[ResourceChange]) -> str: + tf_content = '' + for resource_change in resource_changes: + if not any(item in resource_change.actions for item in ACTIONS_TO_OMIT_RESOURCE): + tf_content += _generate_resource_content(resource_change) + return tf_content + + +def _generate_resource_content(resource_change: ResourceChange) -> str: + resource_content = f'resource "{resource_change.resource_type}" "{resource_change.name}" {{\n' + if resource_change.values is not None: + for key, value in resource_change.values.items(): + resource_content += f' {key} = {json.dumps(value)}\n' + resource_content += '}\n\n' + return resource_content + + +def _extract_resources(tfplan: str, filename: str) -> List[ResourceChange]: + tfplan_json = load_json(tfplan) + resources: List[ResourceChange] = [] + try: + resource_changes = tfplan_json['resource_changes'] + for resource_change in resource_changes: + resources.append( + ResourceChange( + resource_type=resource_change['type'], + name=resource_change['name'], + actions=resource_change['change']['actions'], + values=resource_change['change']['after'], + ) + ) + except (KeyError, TypeError) as e: + raise TfplanKeyError(filename) from e + return resources diff --git a/cycode/cli/models.py b/cycode/cli/models.py index 05713934..d60801df 100644 --- a/cycode/cli/models.py +++ b/cycode/cli/models.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from enum import Enum from typing import Dict, List, NamedTuple, Optional, Type @@ -63,3 +64,14 @@ class LocalScanResult(NamedTuple): issue_detected: bool detections_count: int relevant_detections_count: int + + +@dataclass +class ResourceChange: + resource_type: str + name: str + actions: List[str] + values: Dict[str, str] + + def __repr__(self) -> str: + return f'resource_type: {self.resource_type}, name: {self.name}' diff --git a/cycode/cli/utils/path_utils.py b/cycode/cli/utils/path_utils.py index ef758fed..ad5ce94e 100644 --- a/cycode/cli/utils/path_utils.py +++ b/cycode/cli/utils/path_utils.py @@ -1,3 +1,4 @@ +import json import os from functools import lru_cache from typing import AnyStr, Iterable, List, Optional @@ -71,6 +72,10 @@ def get_file_dir(path: str) -> str: return os.path.dirname(path) +def get_immediate_subdirectories(path: str) -> List[str]: + return [f.name for f in os.scandir(path) if f.is_dir()] + + def join_paths(path: str, filename: str) -> str: return os.path.join(path, filename) @@ -81,3 +86,15 @@ def get_file_content(file_path: str) -> Optional[AnyStr]: return f.read() except (FileNotFoundError, UnicodeDecodeError): return None + + +def load_json(txt: str) -> Optional[dict]: + try: + return json.loads(txt) + except json.JSONDecodeError: + return None + + +def change_filename_extension(filename: str, extension: str) -> str: + base_name, _ = os.path.splitext(filename) + return f'{base_name}.{extension}' diff --git a/tests/cli/helpers/__init__.py b/tests/cli/helpers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/helpers/test_tf_content_generator.py b/tests/cli/helpers/test_tf_content_generator.py new file mode 100644 index 00000000..ae19b2f6 --- /dev/null +++ b/tests/cli/helpers/test_tf_content_generator.py @@ -0,0 +1,16 @@ +import os + +from cycode.cli.helpers import tf_content_generator +from cycode.cli.utils.path_utils import get_file_content, get_immediate_subdirectories +from tests.conftest import TEST_FILES_PATH + +_PATH_TO_EXAMPLES = os.path.join(TEST_FILES_PATH, 'tf_content_generator_files') + + +def test_generate_tf_content_from_tfplan() -> None: + examples_directories = get_immediate_subdirectories(_PATH_TO_EXAMPLES) + for example in examples_directories: + tfplan_content = get_file_content(os.path.join(_PATH_TO_EXAMPLES, example, 'tfplan.json')) + tf_expected_content = get_file_content(os.path.join(_PATH_TO_EXAMPLES, example, 'tf_content.txt')) + tf_content = tf_content_generator.generate_tf_content_from_tfplan(example, tfplan_content) + assert tf_content == tf_expected_content diff --git a/tests/cli/test_code_scanner.py b/tests/cli/test_code_scanner.py index 0bd02845..b715e9c6 100644 --- a/tests/cli/test_code_scanner.py +++ b/tests/cli/test_code_scanner.py @@ -7,8 +7,10 @@ from git import InvalidGitRepositoryError from requests import Response -from cycode.cli.code_scanner import _handle_exception, _is_file_relevant_for_sca_scan +from cycode.cli import consts +from cycode.cli.code_scanner import _generate_document, _handle_exception, _is_file_relevant_for_sca_scan from cycode.cli.exceptions import custom_exceptions +from cycode.cli.models import Document if TYPE_CHECKING: from _pytest.monkeypatch import MonkeyPatch @@ -26,6 +28,7 @@ def ctx() -> click.Context: (custom_exceptions.ScanAsyncError('msg'), True), (custom_exceptions.HttpUnauthorizedError('msg', Response()), True), (custom_exceptions.ZipTooLargeError(1000), True), + (custom_exceptions.TfplanKeyError('msg'), True), (InvalidGitRepositoryError(), None), ], ) @@ -76,3 +79,60 @@ def test_is_file_relevant_for_sca_scan() -> None: assert _is_file_relevant_for_sca_scan(path) is True path = os.path.join('some_package', 'package.lock') assert _is_file_relevant_for_sca_scan(path) is True + + +def test_generate_document() -> None: + is_git_diff = False + + path = 'path/to/nowhere.txt' + content = 'nothing important here' + + non_iac_document = Document(path, content, is_git_diff) + generated_document = _generate_document(path, consts.SCA_SCAN_TYPE, content, is_git_diff) + + assert non_iac_document.path == generated_document.path + assert non_iac_document.content == generated_document.content + assert non_iac_document.is_git_diff_format == generated_document.is_git_diff_format + + path = 'path/to/nowhere.tf' + content = """provider "aws" { + profile = "chili" + region = "us-east-1" + } + + resource "aws_s3_bucket" "chili-env-var-test" { + bucket = "chili-env-var-test" + }""" + + iac_document = Document(path, content, is_git_diff) + generated_document = _generate_document(path, consts.INFRA_CONFIGURATION_SCAN_TYPE, content, is_git_diff) + assert iac_document.path == generated_document.path + assert iac_document.content == generated_document.content + assert iac_document.is_git_diff_format == generated_document.is_git_diff_format + + content = """ + { + "resource_changes":[ + { + "type":"aws_s3_bucket_public_access_block", + "name":"efrat-env-var-test", + "change":{ + "actions":[ + "create" + ], + "after":{ + "block_public_acls":false, + "block_public_policy":true, + "ignore_public_acls":false, + "restrict_public_buckets":true + } + } + ] + } + """ + + generated_tfplan_document = _generate_document(path, consts.INFRA_CONFIGURATION_SCAN_TYPE, content, is_git_diff) + + assert type(generated_tfplan_document) == Document + assert generated_tfplan_document.path.endswith('.tf') + assert generated_tfplan_document.is_git_diff_format == is_git_diff diff --git a/tests/test_files/tf_content_generator_files/tfplan-create-example/tf_content.txt b/tests/test_files/tf_content_generator_files/tfplan-create-example/tf_content.txt new file mode 100644 index 00000000..4021097e --- /dev/null +++ b/tests/test_files/tf_content_generator_files/tfplan-create-example/tf_content.txt @@ -0,0 +1,113 @@ +resource "aws_codebuild_project" "terra_ci" { + artifacts = [{"artifact_identifier": null, "encryption_disabled": false, "location": "terra-ci-artifacts-eu-west-1-000002", "name": null, "namespace_type": null, "override_artifact_name": false, "packaging": null, "path": null, "type": "S3"}] + badge_enabled = false + build_timeout = 10 + cache = [] + description = "Deploy environment configuration" + environment = [{"certificate": null, "compute_type": "BUILD_GENERAL1_SMALL", "environment_variable": [], "image": "aws/codebuild/amazonlinux2-x86_64-standard:2.0", "image_pull_credentials_type": "CODEBUILD", "privileged_mode": false, "registry_credential": [], "type": "LINUX_CONTAINER"}] + logs_config = [{"cloudwatch_logs": [{"group_name": null, "status": "ENABLED", "stream_name": null}], "s3_logs": [{"encryption_disabled": false, "location": null, "status": "DISABLED"}]}] + name = "terra-ci-runner" + queued_timeout = 480 + secondary_artifacts = [] + secondary_sources = [] + source = [{"auth": [], "buildspec": "version: 0.2\nphases:\n install:\n commands:\n - make install_tools\n build:\n commands:\n - make plan_local resource=$TERRA_CI_RESOURCE\nartifacts:\n files:\n - ./tfplan\n name: $TERRA_CI_BUILD_NAME\n\n", "git_clone_depth": 1, "git_submodules_config": [], "insecure_ssl": false, "location": "https://github.com/p0tr3c-terraform/terra-ci-single-account.git", "report_build_status": false, "type": "GITHUB"}] + source_version = null + tags = null + vpc_config = [] +} + +resource "aws_iam_role" "terra_ci_job" { + assume_role_policy = "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Principal\": {\n \"Service\": \"codebuild.amazonaws.com\"\n },\n \"Action\": \"sts:AssumeRole\"\n }\n ]\n}\n" + description = null + force_detach_policies = false + max_session_duration = 3600 + name = "terra_ci_job" + name_prefix = null + path = "/" + permissions_boundary = null + tags = null +} + +resource "aws_iam_role" "terra_ci_runner" { + assume_role_policy = "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Principal\": {\n \"Service\": \"states.amazonaws.com\"\n },\n \"Action\": \"sts:AssumeRole\"\n }\n ]\n}\n" + description = null + force_detach_policies = false + max_session_duration = 3600 + name = "terra_ci_runner" + name_prefix = null + path = "/" + permissions_boundary = null + tags = null +} + +resource "aws_iam_role_policy" "terra_ci_job" { + name_prefix = null + role = "terra_ci_job" +} + +resource "aws_iam_role_policy" "terra_ci_runner" { + name_prefix = null + role = "terra_ci_runner" +} + +resource "aws_iam_role_policy_attachment" "terra_ci_job_ecr_access" { + policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser" + role = "terra_ci_job" +} + +resource "aws_s3_bucket" "terra_ci" { + acl = "private" + bucket = "terra-ci-artifacts-eu-west-1-000002" + bucket_prefix = null + cors_rule = [] + force_destroy = false + grant = [] + lifecycle_rule = [] + logging = [] + object_lock_configuration = [] + policy = null + replication_configuration = [] + server_side_encryption_configuration = [{"rule": [{"apply_server_side_encryption_by_default": [{"kms_master_key_id": null, "sse_algorithm": "aws:kms"}], "bucket_key_enabled": false}]}] + tags = null + website = [] +} + +resource "aws_sfn_state_machine" "terra_ci_runner" { + definition = "{\n \"Comment\": \"Run Terragrunt Jobs\",\n \"StartAt\": \"OnBranch?\",\n \"States\": {\n \"OnBranch?\": {\n \"Type\": \"Choice\",\n \"Choices\": [\n {\n \"Variable\": \"$.build.sourceversion\",\n \"IsPresent\": true,\n \"Next\": \"PlanBranch\"\n }\n ],\n \"Default\": \"Plan\"\n },\n \"Plan\": {\n \"Type\": \"Task\",\n \"Resource\": \"arn:aws:states:::codebuild:startBuild.sync\",\n \"Parameters\": {\n \"ProjectName\": \"terra-ci-runner\",\n \"EnvironmentVariablesOverride\": [\n {\n \"Name\": \"TERRA_CI_BUILD_NAME\",\n \"Value.$\": \"$$.Execution.Name\"\n },\n {\n \"Name\": \"TERRA_CI_RESOURCE\",\n \"Value.$\": \"$.build.environment.terra_ci_resource\"\n }\n ]\n },\n \"End\": true\n },\n \"PlanBranch\": {\n \"Type\": \"Task\",\n \"Resource\": \"arn:aws:states:::codebuild:startBuild.sync\",\n \"Parameters\": {\n \"ProjectName\": \"terra-ci-runner\",\n \"SourceVersion.$\": \"$.build.sourceversion\",\n \"EnvironmentVariablesOverride\": [\n {\n \"Name\": \"TERRA_CI_RESOURCE\",\n \"Value.$\": \"$.build.environment.terra_ci_resource\"\n }\n ]\n },\n \"End\": true\n }\n }\n}\n" + name = "terra-ci-runner" + tags = null + type = "STANDARD" +} + +resource "aws_route" "private" { + carrier_gateway_id = null + destination_cidr_block = "172.25.16.0/20" + destination_ipv6_cidr_block = null + destination_prefix_list_id = null + egress_only_gateway_id = null + gateway_id = null + local_gateway_id = null + nat_gateway_id = null + route_table_id = "rtb-00cf8381520103cfb" + timeouts = null + transit_gateway_id = "tgw-0f68a4f2c58772c51" + vpc_endpoint_id = null + vpc_peering_connection_id = null +} + +resource "aws_route" "private" { + carrier_gateway_id = null + destination_cidr_block = "172.25.16.0/20" + destination_ipv6_cidr_block = null + destination_prefix_list_id = null + egress_only_gateway_id = null + gateway_id = null + local_gateway_id = null + nat_gateway_id = null + route_table_id = "rtb-00cf8381520103cfb" + timeouts = null + transit_gateway_id = "tgw-0f68a4f2c58772c51" + vpc_endpoint_id = null + vpc_peering_connection_id = null +} + diff --git a/tests/test_files/tf_content_generator_files/tfplan-create-example/tfplan.json b/tests/test_files/tf_content_generator_files/tfplan-create-example/tfplan.json new file mode 100644 index 00000000..0b3a9296 --- /dev/null +++ b/tests/test_files/tf_content_generator_files/tfplan-create-example/tfplan.json @@ -0,0 +1,1072 @@ +{ + "comment-for-reader": "THIS TERRAFORM FILE REPRESENTS A REAL TF-PLAN OUTPUT FOR NEWLY CREATED INFRASTRUCTRE", + "format_version": "0.1", + "terraform_version": "0.15.0", + "variables": { + "aws_region": { + "value": "eu-west-1" + }, + "repo_url": { + "value": "https://github.com/p0tr3c-terraform/terra-ci-single-account.git" + }, + "serial_number": { + "value": "000002" + } + }, + "planned_values": { + "root_module": { + "resources": [ + { + "address": "aws_codebuild_project.terra_ci", + "mode": "managed", + "type": "aws_codebuild_project", + "name": "terra_ci", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "artifacts": [ + { + "artifact_identifier": null, + "encryption_disabled": false, + "location": "terra-ci-artifacts-eu-west-1-000002", + "name": null, + "namespace_type": null, + "override_artifact_name": false, + "packaging": null, + "path": null, + "type": "S3" + } + ], + "badge_enabled": false, + "build_timeout": 10, + "cache": [], + "description": "Deploy environment configuration", + "environment": [ + { + "certificate": null, + "compute_type": "BUILD_GENERAL1_SMALL", + "environment_variable": [], + "image": "aws/codebuild/amazonlinux2-x86_64-standard:2.0", + "image_pull_credentials_type": "CODEBUILD", + "privileged_mode": false, + "registry_credential": [], + "type": "LINUX_CONTAINER" + } + ], + "logs_config": [ + { + "cloudwatch_logs": [ + { + "group_name": null, + "status": "ENABLED", + "stream_name": null + } + ], + "s3_logs": [ + { + "encryption_disabled": false, + "location": null, + "status": "DISABLED" + } + ] + } + ], + "name": "terra-ci-runner", + "queued_timeout": 480, + "secondary_artifacts": [], + "secondary_sources": [], + "source": [ + { + "auth": [], + "buildspec": "version: 0.2\nphases:\n install:\n commands:\n - make install_tools\n build:\n commands:\n - make plan_local resource=$TERRA_CI_RESOURCE\nartifacts:\n files:\n - ./tfplan\n name: $TERRA_CI_BUILD_NAME\n\n", + "git_clone_depth": 1, + "git_submodules_config": [], + "insecure_ssl": false, + "location": "https://github.com/p0tr3c-terraform/terra-ci-single-account.git", + "report_build_status": false, + "type": "GITHUB" + } + ], + "source_version": null, + "tags": null, + "vpc_config": [] + } + }, + { + "address": "aws_iam_role.terra_ci_job", + "mode": "managed", + "type": "aws_iam_role", + "name": "terra_ci_job", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "assume_role_policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Principal\": {\n \"Service\": \"codebuild.amazonaws.com\"\n },\n \"Action\": \"sts:AssumeRole\"\n }\n ]\n}\n", + "description": null, + "force_detach_policies": false, + "max_session_duration": 3600, + "name": "terra_ci_job", + "name_prefix": null, + "path": "/", + "permissions_boundary": null, + "tags": null + } + }, + { + "address": "aws_iam_role.terra_ci_runner", + "mode": "managed", + "type": "aws_iam_role", + "name": "terra_ci_runner", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "assume_role_policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Principal\": {\n \"Service\": \"states.amazonaws.com\"\n },\n \"Action\": \"sts:AssumeRole\"\n }\n ]\n}\n", + "description": null, + "force_detach_policies": false, + "max_session_duration": 3600, + "name": "terra_ci_runner", + "name_prefix": null, + "path": "/", + "permissions_boundary": null, + "tags": null + } + }, + { + "address": "aws_iam_role_policy.terra_ci_job", + "mode": "managed", + "type": "aws_iam_role_policy", + "name": "terra_ci_job", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "name_prefix": null, + "role": "terra_ci_job" + } + }, + { + "address": "aws_iam_role_policy.terra_ci_runner", + "mode": "managed", + "type": "aws_iam_role_policy", + "name": "terra_ci_runner", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "name_prefix": null, + "role": "terra_ci_runner" + } + }, + { + "address": "aws_iam_role_policy_attachment.terra_ci_job_ecr_access", + "mode": "managed", + "type": "aws_iam_role_policy_attachment", + "name": "terra_ci_job_ecr_access", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "policy_arn": "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser", + "role": "terra_ci_job" + } + }, + { + "address": "aws_s3_bucket.terra_ci", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "terra_ci", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "acl": "private", + "bucket": "terra-ci-artifacts-eu-west-1-000002", + "bucket_prefix": null, + "cors_rule": [], + "force_destroy": false, + "grant": [], + "lifecycle_rule": [], + "logging": [], + "object_lock_configuration": [], + "policy": null, + "replication_configuration": [], + "server_side_encryption_configuration": [ + { + "rule": [ + { + "apply_server_side_encryption_by_default": [ + { + "kms_master_key_id": null, + "sse_algorithm": "aws:kms" + } + ], + "bucket_key_enabled": false + } + ] + } + ], + "tags": null, + "website": [] + } + }, + { + "address": "aws_sfn_state_machine.terra_ci_runner", + "mode": "managed", + "type": "aws_sfn_state_machine", + "name": "terra_ci_runner", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "definition": "{\n \"Comment\": \"Run Terragrunt Jobs\",\n \"StartAt\": \"OnBranch?\",\n \"States\": {\n \"OnBranch?\": {\n \"Type\": \"Choice\",\n \"Choices\": [\n {\n \"Variable\": \"$.build.sourceversion\",\n \"IsPresent\": true,\n \"Next\": \"PlanBranch\"\n }\n ],\n \"Default\": \"Plan\"\n },\n \"Plan\": {\n \"Type\": \"Task\",\n \"Resource\": \"arn:aws:states:::codebuild:startBuild.sync\",\n \"Parameters\": {\n \"ProjectName\": \"terra-ci-runner\",\n \"EnvironmentVariablesOverride\": [\n {\n \"Name\": \"TERRA_CI_BUILD_NAME\",\n \"Value.$\": \"$$.Execution.Name\"\n },\n {\n \"Name\": \"TERRA_CI_RESOURCE\",\n \"Value.$\": \"$.build.environment.terra_ci_resource\"\n }\n ]\n },\n \"End\": true\n },\n \"PlanBranch\": {\n \"Type\": \"Task\",\n \"Resource\": \"arn:aws:states:::codebuild:startBuild.sync\",\n \"Parameters\": {\n \"ProjectName\": \"terra-ci-runner\",\n \"SourceVersion.$\": \"$.build.sourceversion\",\n \"EnvironmentVariablesOverride\": [\n {\n \"Name\": \"TERRA_CI_RESOURCE\",\n \"Value.$\": \"$.build.environment.terra_ci_resource\"\n }\n ]\n },\n \"End\": true\n }\n }\n}\n", + "name": "terra-ci-runner", + "tags": null, + "type": "STANDARD" + } + } + ] + } + }, + "resource_changes": [ + { + "address": "aws_codebuild_project.terra_ci", + "mode": "managed", + "type": "aws_codebuild_project", + "name": "terra_ci", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "artifacts": [ + { + "artifact_identifier": null, + "encryption_disabled": false, + "location": "terra-ci-artifacts-eu-west-1-000002", + "name": null, + "namespace_type": null, + "override_artifact_name": false, + "packaging": null, + "path": null, + "type": "S3" + } + ], + "badge_enabled": false, + "build_timeout": 10, + "cache": [], + "description": "Deploy environment configuration", + "environment": [ + { + "certificate": null, + "compute_type": "BUILD_GENERAL1_SMALL", + "environment_variable": [], + "image": "aws/codebuild/amazonlinux2-x86_64-standard:2.0", + "image_pull_credentials_type": "CODEBUILD", + "privileged_mode": false, + "registry_credential": [], + "type": "LINUX_CONTAINER" + } + ], + "logs_config": [ + { + "cloudwatch_logs": [ + { + "group_name": null, + "status": "ENABLED", + "stream_name": null + } + ], + "s3_logs": [ + { + "encryption_disabled": false, + "location": null, + "status": "DISABLED" + } + ] + } + ], + "name": "terra-ci-runner", + "queued_timeout": 480, + "secondary_artifacts": [], + "secondary_sources": [], + "source": [ + { + "auth": [], + "buildspec": "version: 0.2\nphases:\n install:\n commands:\n - make install_tools\n build:\n commands:\n - make plan_local resource=$TERRA_CI_RESOURCE\nartifacts:\n files:\n - ./tfplan\n name: $TERRA_CI_BUILD_NAME\n\n", + "git_clone_depth": 1, + "git_submodules_config": [], + "insecure_ssl": false, + "location": "https://github.com/p0tr3c-terraform/terra-ci-single-account.git", + "report_build_status": false, + "type": "GITHUB" + } + ], + "source_version": null, + "tags": null, + "vpc_config": [] + }, + "after_unknown": { + "arn": true, + "artifacts": [ + {} + ], + "badge_url": true, + "cache": [], + "encryption_key": true, + "environment": [ + { + "environment_variable": [], + "registry_credential": [] + } + ], + "id": true, + "logs_config": [ + { + "cloudwatch_logs": [ + {} + ], + "s3_logs": [ + {} + ] + } + ], + "secondary_artifacts": [], + "secondary_sources": [], + "service_role": true, + "source": [ + { + "auth": [], + "git_submodules_config": [] + } + ], + "vpc_config": [] + }, + "before_sensitive": false, + "after_sensitive": { + "artifacts": [ + {} + ], + "cache": [], + "environment": [ + { + "environment_variable": [], + "registry_credential": [] + } + ], + "logs_config": [ + { + "cloudwatch_logs": [ + {} + ], + "s3_logs": [ + {} + ] + } + ], + "secondary_artifacts": [], + "secondary_sources": [], + "source": [ + { + "auth": [], + "git_submodules_config": [] + } + ], + "vpc_config": [] + } + } + }, + { + "address": "aws_iam_role.terra_ci_job", + "mode": "managed", + "type": "aws_iam_role", + "name": "terra_ci_job", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "assume_role_policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Principal\": {\n \"Service\": \"codebuild.amazonaws.com\"\n },\n \"Action\": \"sts:AssumeRole\"\n }\n ]\n}\n", + "description": null, + "force_detach_policies": false, + "max_session_duration": 3600, + "name": "terra_ci_job", + "name_prefix": null, + "path": "/", + "permissions_boundary": null, + "tags": null + }, + "after_unknown": { + "arn": true, + "create_date": true, + "id": true, + "inline_policy": true, + "managed_policy_arns": true, + "unique_id": true + }, + "before_sensitive": false, + "after_sensitive": { + "inline_policy": [], + "managed_policy_arns": [] + } + } + }, + { + "address": "aws_iam_role.terra_ci_runner", + "mode": "managed", + "type": "aws_iam_role", + "name": "terra_ci_runner", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "assume_role_policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Principal\": {\n \"Service\": \"states.amazonaws.com\"\n },\n \"Action\": \"sts:AssumeRole\"\n }\n ]\n}\n", + "description": null, + "force_detach_policies": false, + "max_session_duration": 3600, + "name": "terra_ci_runner", + "name_prefix": null, + "path": "/", + "permissions_boundary": null, + "tags": null + }, + "after_unknown": { + "arn": true, + "create_date": true, + "id": true, + "inline_policy": true, + "managed_policy_arns": true, + "unique_id": true + }, + "before_sensitive": false, + "after_sensitive": { + "inline_policy": [], + "managed_policy_arns": [] + } + } + }, + { + "address": "aws_iam_role_policy.terra_ci_job", + "mode": "managed", + "type": "aws_iam_role_policy", + "name": "terra_ci_job", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name_prefix": null, + "role": "terra_ci_job" + }, + "after_unknown": { + "id": true, + "name": true, + "policy": true + }, + "before_sensitive": false, + "after_sensitive": {} + } + }, + { + "address": "aws_iam_role_policy.terra_ci_runner", + "mode": "managed", + "type": "aws_iam_role_policy", + "name": "terra_ci_runner", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name_prefix": null, + "role": "terra_ci_runner" + }, + "after_unknown": { + "id": true, + "name": true, + "policy": true + }, + "before_sensitive": false, + "after_sensitive": {} + } + }, + { + "address": "aws_iam_role_policy_attachment.terra_ci_job_ecr_access", + "mode": "managed", + "type": "aws_iam_role_policy_attachment", + "name": "terra_ci_job_ecr_access", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "policy_arn": "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser", + "role": "terra_ci_job" + }, + "after_unknown": { + "id": true + }, + "before_sensitive": false, + "after_sensitive": {} + } + }, + { + "address": "aws_s3_bucket.terra_ci", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "terra_ci", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "acl": "private", + "bucket": "terra-ci-artifacts-eu-west-1-000002", + "bucket_prefix": null, + "cors_rule": [], + "force_destroy": false, + "grant": [], + "lifecycle_rule": [], + "logging": [], + "object_lock_configuration": [], + "policy": null, + "replication_configuration": [], + "server_side_encryption_configuration": [ + { + "rule": [ + { + "apply_server_side_encryption_by_default": [ + { + "kms_master_key_id": null, + "sse_algorithm": "aws:kms" + } + ], + "bucket_key_enabled": false + } + ] + } + ], + "tags": null, + "website": [] + }, + "after_unknown": { + "acceleration_status": true, + "arn": true, + "bucket_domain_name": true, + "bucket_regional_domain_name": true, + "cors_rule": [], + "grant": [], + "hosted_zone_id": true, + "id": true, + "lifecycle_rule": [], + "logging": [], + "object_lock_configuration": [], + "region": true, + "replication_configuration": [], + "request_payer": true, + "server_side_encryption_configuration": [ + { + "rule": [ + { + "apply_server_side_encryption_by_default": [ + {} + ] + } + ] + } + ], + "versioning": true, + "website": [], + "website_domain": true, + "website_endpoint": true + }, + "before_sensitive": false, + "after_sensitive": { + "cors_rule": [], + "grant": [], + "lifecycle_rule": [], + "logging": [], + "object_lock_configuration": [], + "replication_configuration": [], + "server_side_encryption_configuration": [ + { + "rule": [ + { + "apply_server_side_encryption_by_default": [ + {} + ] + } + ] + } + ], + "versioning": [], + "website": [] + } + } + }, + { + "address": "aws_sfn_state_machine.terra_ci_runner", + "mode": "managed", + "type": "aws_sfn_state_machine", + "name": "terra_ci_runner", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "definition": "{\n \"Comment\": \"Run Terragrunt Jobs\",\n \"StartAt\": \"OnBranch?\",\n \"States\": {\n \"OnBranch?\": {\n \"Type\": \"Choice\",\n \"Choices\": [\n {\n \"Variable\": \"$.build.sourceversion\",\n \"IsPresent\": true,\n \"Next\": \"PlanBranch\"\n }\n ],\n \"Default\": \"Plan\"\n },\n \"Plan\": {\n \"Type\": \"Task\",\n \"Resource\": \"arn:aws:states:::codebuild:startBuild.sync\",\n \"Parameters\": {\n \"ProjectName\": \"terra-ci-runner\",\n \"EnvironmentVariablesOverride\": [\n {\n \"Name\": \"TERRA_CI_BUILD_NAME\",\n \"Value.$\": \"$$.Execution.Name\"\n },\n {\n \"Name\": \"TERRA_CI_RESOURCE\",\n \"Value.$\": \"$.build.environment.terra_ci_resource\"\n }\n ]\n },\n \"End\": true\n },\n \"PlanBranch\": {\n \"Type\": \"Task\",\n \"Resource\": \"arn:aws:states:::codebuild:startBuild.sync\",\n \"Parameters\": {\n \"ProjectName\": \"terra-ci-runner\",\n \"SourceVersion.$\": \"$.build.sourceversion\",\n \"EnvironmentVariablesOverride\": [\n {\n \"Name\": \"TERRA_CI_RESOURCE\",\n \"Value.$\": \"$.build.environment.terra_ci_resource\"\n }\n ]\n },\n \"End\": true\n }\n }\n}\n", + "name": "terra-ci-runner", + "tags": null, + "type": "STANDARD" + }, + "after_unknown": { + "arn": true, + "creation_date": true, + "id": true, + "logging_configuration": true, + "role_arn": true, + "status": true + }, + "before_sensitive": false, + "after_sensitive": { + "logging_configuration": [] + } + } + }, + { + "address": "aws_route.private[\"1\"]", + "mode": "managed", + "type": "aws_route", + "name": "private", + "index": 1, + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "carrier_gateway_id": null, + "destination_cidr_block": "172.25.16.0/20", + "destination_ipv6_cidr_block": null, + "destination_prefix_list_id": null, + "egress_only_gateway_id": null, + "gateway_id": null, + "local_gateway_id": null, + "nat_gateway_id": null, + "route_table_id": "rtb-00cf8381520103cfb", + "timeouts": null, + "transit_gateway_id": "tgw-0f68a4f2c58772c51", + "vpc_endpoint_id": null, + "vpc_peering_connection_id": null + }, + "after_unknown": { + "id": true, + "instance_id": true, + "instance_owner_id": true, + "network_interface_id": true, + "origin": true, + "state": true + }, + "before_sensitive": false, + "after_sensitive": {} + } + }, + { + "address": "aws_route.private[\"rtb-00cf8381520103cfb\"]", + "mode": "managed", + "type": "aws_route", + "name": "private", + "index": "rtb-00cf8381520103cfb", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "carrier_gateway_id": null, + "destination_cidr_block": "172.25.16.0/20", + "destination_ipv6_cidr_block": null, + "destination_prefix_list_id": null, + "egress_only_gateway_id": null, + "gateway_id": null, + "local_gateway_id": null, + "nat_gateway_id": null, + "route_table_id": "rtb-00cf8381520103cfb", + "timeouts": null, + "transit_gateway_id": "tgw-0f68a4f2c58772c51", + "vpc_endpoint_id": null, + "vpc_peering_connection_id": null + }, + "after_unknown": { + "id": true, + "instance_id": true, + "instance_owner_id": true, + "network_interface_id": true, + "origin": true, + "state": true + }, + "before_sensitive": false, + "after_sensitive": {} + } + } + ], + "prior_state": { + "format_version": "0.1", + "terraform_version": "0.15.0", + "values": { + "root_module": { + "resources": [ + { + "address": "data.aws_caller_identity.current", + "mode": "data", + "type": "aws_caller_identity", + "name": "current", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "account_id": "719261439472", + "arn": "arn:aws:sts::719261439472:assumed-role/ci/1620222847597477484", + "id": "719261439472", + "user_id": "AROA2O52SSXYLVFYURBIV:1620222847597477484" + } + }, + { + "address": "data.template_file.terra_ci", + "mode": "data", + "type": "template_file", + "name": "terra_ci", + "provider_name": "registry.terraform.io/hashicorp/template", + "schema_version": 0, + "values": { + "filename": null, + "id": "64e36ed71e7270140dde96fec9c89d1d55ae5a6e91f7c0be15170200dcf9481b", + "rendered": "version: 0.2\nphases:\n install:\n commands:\n - make install_tools\n build:\n commands:\n - make plan_local resource=$TERRA_CI_RESOURCE\nartifacts:\n files:\n - ./tfplan\n name: $TERRA_CI_BUILD_NAME\n\n", + "template": "version: 0.2\nphases:\n install:\n commands:\n - make install_tools\n build:\n commands:\n - make plan_local resource=$TERRA_CI_RESOURCE\nartifacts:\n files:\n - ./tfplan\n name: $TERRA_CI_BUILD_NAME\n\n", + "vars": null + } + } + ] + } + } + }, + "configuration": { + "provider_config": { + "aws": { + "name": "aws", + "expressions": { + "allowed_account_ids": { + "constant_value": [ + "719261439472" + ] + }, + "assume_role": [ + { + "role_arn": { + "constant_value": "arn:aws:iam::719261439472:role/ci" + } + } + ], + "region": { + "constant_value": "eu-west-1" + } + } + } + }, + "root_module": { + "resources": [ + { + "address": "aws_codebuild_project.terra_ci", + "mode": "managed", + "type": "aws_codebuild_project", + "name": "terra_ci", + "provider_config_key": "aws", + "expressions": { + "artifacts": [ + { + "location": { + "references": [ + "aws_s3_bucket.terra_ci" + ] + }, + "type": { + "constant_value": "S3" + } + } + ], + "build_timeout": { + "constant_value": "10" + }, + "description": { + "constant_value": "Deploy environment configuration" + }, + "environment": [ + { + "compute_type": { + "constant_value": "BUILD_GENERAL1_SMALL" + }, + "image": { + "constant_value": "aws/codebuild/amazonlinux2-x86_64-standard:2.0" + }, + "image_pull_credentials_type": { + "constant_value": "CODEBUILD" + }, + "privileged_mode": { + "constant_value": false + }, + "type": { + "constant_value": "LINUX_CONTAINER" + } + } + ], + "logs_config": [ + { + "cloudwatch_logs": [ + { + "status": { + "constant_value": "ENABLED" + } + } + ], + "s3_logs": [ + { + "encryption_disabled": { + "constant_value": false + }, + "status": { + "constant_value": "DISABLED" + } + } + ] + } + ], + "name": { + "constant_value": "terra-ci-runner" + }, + "service_role": { + "references": [ + "aws_iam_role.terra_ci_job" + ] + }, + "source": [ + { + "buildspec": { + "references": [ + "data.template_file.terra_ci" + ] + }, + "git_clone_depth": { + "constant_value": 1 + }, + "insecure_ssl": { + "constant_value": false + }, + "location": { + "references": [ + "var.repo_url" + ] + }, + "report_build_status": { + "constant_value": false + }, + "type": { + "constant_value": "GITHUB" + } + } + ] + }, + "schema_version": 0 + }, + { + "address": "aws_iam_role.terra_ci_job", + "mode": "managed", + "type": "aws_iam_role", + "name": "terra_ci_job", + "provider_config_key": "aws", + "expressions": { + "assume_role_policy": { + "constant_value": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Principal\": {\n \"Service\": \"codebuild.amazonaws.com\"\n },\n \"Action\": \"sts:AssumeRole\"\n }\n ]\n}\n" + }, + "name": { + "constant_value": "terra_ci_job" + } + }, + "schema_version": 0 + }, + { + "address": "aws_iam_role.terra_ci_runner", + "mode": "managed", + "type": "aws_iam_role", + "name": "terra_ci_runner", + "provider_config_key": "aws", + "expressions": { + "assume_role_policy": { + "constant_value": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Principal\": {\n \"Service\": \"states.amazonaws.com\"\n },\n \"Action\": \"sts:AssumeRole\"\n }\n ]\n}\n" + }, + "name": { + "constant_value": "terra_ci_runner" + } + }, + "schema_version": 0 + }, + { + "address": "aws_iam_role_policy.terra_ci_job", + "mode": "managed", + "type": "aws_iam_role_policy", + "name": "terra_ci_job", + "provider_config_key": "aws", + "expressions": { + "policy": { + "references": [ + "data.aws_caller_identity.current", + "aws_s3_bucket.terra_ci", + "aws_s3_bucket.terra_ci" + ] + }, + "role": { + "references": [ + "aws_iam_role.terra_ci_job" + ] + } + }, + "schema_version": 0 + }, + { + "address": "aws_iam_role_policy.terra_ci_runner", + "mode": "managed", + "type": "aws_iam_role_policy", + "name": "terra_ci_runner", + "provider_config_key": "aws", + "expressions": { + "policy": { + "references": [ + "aws_codebuild_project.terra_ci", + "var.aws_region", + "data.aws_caller_identity.current" + ] + }, + "role": { + "references": [ + "aws_iam_role.terra_ci_runner" + ] + } + }, + "schema_version": 0 + }, + { + "address": "aws_iam_role_policy_attachment.terra_ci_job_ecr_access", + "mode": "managed", + "type": "aws_iam_role_policy_attachment", + "name": "terra_ci_job_ecr_access", + "provider_config_key": "aws", + "expressions": { + "policy_arn": { + "constant_value": "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser" + }, + "role": { + "references": [ + "aws_iam_role.terra_ci_job" + ] + } + }, + "schema_version": 0 + }, + { + "address": "aws_s3_bucket.terra_ci", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "terra_ci", + "provider_config_key": "aws", + "expressions": { + "acl": { + "constant_value": "private" + }, + "bucket": { + "references": [ + "var.aws_region", + "var.serial_number" + ] + }, + "server_side_encryption_configuration": [ + { + "rule": [ + { + "apply_server_side_encryption_by_default": [ + { + "sse_algorithm": { + "constant_value": "aws:kms" + } + } + ], + "bucket_key_enabled": { + "constant_value": false + } + } + ] + } + ] + }, + "schema_version": 0 + }, + { + "address": "aws_sfn_state_machine.terra_ci_runner", + "mode": "managed", + "type": "aws_sfn_state_machine", + "name": "terra_ci_runner", + "provider_config_key": "aws", + "expressions": { + "definition": { + "references": [ + "aws_codebuild_project.terra_ci", + "aws_codebuild_project.terra_ci" + ] + }, + "name": { + "constant_value": "terra-ci-runner" + }, + "role_arn": { + "references": [ + "aws_iam_role.terra_ci_runner" + ] + } + }, + "schema_version": 0 + }, + { + "address": "data.aws_caller_identity.current", + "mode": "data", + "type": "aws_caller_identity", + "name": "current", + "provider_config_key": "aws", + "schema_version": 0 + }, + { + "address": "data.template_file.terra_ci", + "mode": "data", + "type": "template_file", + "name": "terra_ci", + "provider_config_key": "template", + "expressions": { + "template": {} + }, + "schema_version": 0 + } + ], + "variables": { + "aws_region": {}, + "repo_url": {}, + "serial_number": {} + } + } + } + } diff --git a/tests/test_files/tf_content_generator_files/tfplan-destroy-example/tf_content.txt b/tests/test_files/tf_content_generator_files/tfplan-destroy-example/tf_content.txt new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_files/tf_content_generator_files/tfplan-destroy-example/tfplan.json b/tests/test_files/tf_content_generator_files/tfplan-destroy-example/tfplan.json new file mode 100644 index 00000000..88176ec9 --- /dev/null +++ b/tests/test_files/tf_content_generator_files/tfplan-destroy-example/tfplan.json @@ -0,0 +1,1082 @@ +{ + "comment-for-reader": "THIS TERRAFORM FILE REPRESENTS A REAL TF-PLAN OUTPUT FOR DELETED INFRASTRUCTRE", + "format_version": "0.1", + "terraform_version": "0.15.0", + "variables": { + "aws_region": { + "value": "eu-west-1" + }, + "repo_url": { + "value": "https://github.com/p0tr3c-terraform/terra-ci-single-account.git" + }, + "serial_number": { + "value": "000002" + } + }, + "planned_values": { + "root_module": {} + }, + "resource_changes": [ + { + "address": "aws_codebuild_project.terra_ci", + "mode": "managed", + "type": "aws_codebuild_project", + "name": "terra_ci", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "delete" + ], + "before": { + "arn": "arn:aws:codebuild:eu-west-1:719261439472:project/terra-ci-runner", + "artifacts": [ + { + "artifact_identifier": "", + "encryption_disabled": false, + "location": "terra-ci-artifacts-eu-west-1-000002", + "name": "terra-ci-runner", + "namespace_type": "NONE", + "override_artifact_name": false, + "packaging": "NONE", + "path": "", + "type": "S3" + } + ], + "badge_enabled": false, + "badge_url": "", + "build_timeout": 10, + "cache": [ + { + "location": "", + "modes": [], + "type": "NO_CACHE" + } + ], + "description": "Deploy environment configuration", + "encryption_key": "arn:aws:kms:eu-west-1:719261439472:alias/aws/s3", + "environment": [ + { + "certificate": "", + "compute_type": "BUILD_GENERAL1_SMALL", + "environment_variable": [], + "image": "aws/codebuild/amazonlinux2-x86_64-standard:2.0", + "image_pull_credentials_type": "CODEBUILD", + "privileged_mode": false, + "registry_credential": [], + "type": "LINUX_CONTAINER" + } + ], + "id": "arn:aws:codebuild:eu-west-1:719261439472:project/terra-ci-runner", + "logs_config": [ + { + "cloudwatch_logs": [ + { + "group_name": "", + "status": "ENABLED", + "stream_name": "" + } + ], + "s3_logs": [ + { + "encryption_disabled": false, + "location": "", + "status": "DISABLED" + } + ] + } + ], + "name": "terra-ci-runner", + "queued_timeout": 480, + "secondary_artifacts": [], + "secondary_sources": [], + "service_role": "arn:aws:iam::719261439472:role/terra_ci_job", + "source": [ + { + "auth": [], + "buildspec": "version: 0.2\nphases:\n install:\n commands:\n - make install_tools\n build:\n commands:\n - make plan_local resource=$TERRA_CI_RESOURCE\nartifacts:\n files:\n - ./tfplan\n name: $TERRA_CI_BUILD_NAME\n\n", + "git_clone_depth": 1, + "git_submodules_config": [], + "insecure_ssl": false, + "location": "https://github.com/p0tr3c-terraform/terra-ci-single-account.git", + "report_build_status": false, + "type": "GITHUB" + } + ], + "source_version": "", + "tags": {}, + "vpc_config": [] + }, + "after": null, + "after_unknown": {}, + "before_sensitive": { + "artifacts": [ + {} + ], + "cache": [ + { + "modes": [] + } + ], + "environment": [ + { + "environment_variable": [], + "registry_credential": [] + } + ], + "logs_config": [ + { + "cloudwatch_logs": [ + {} + ], + "s3_logs": [ + {} + ] + } + ], + "secondary_artifacts": [], + "secondary_sources": [], + "source": [ + { + "auth": [], + "git_submodules_config": [] + } + ], + "tags": {}, + "vpc_config": [] + }, + "after_sensitive": false + } + }, + { + "address": "aws_iam_role.terra_ci_job", + "mode": "managed", + "type": "aws_iam_role", + "name": "terra_ci_job", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "delete" + ], + "before": { + "arn": "arn:aws:iam::719261439472:role/terra_ci_job", + "assume_role_policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"codebuild.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}", + "create_date": "2021-05-05T13:59:07Z", + "description": "", + "force_detach_policies": false, + "id": "terra_ci_job", + "inline_policy": [ + { + "name": "terraform-20210505135914255500000001", + "policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": \"sts:AssumeRole\",\n \"Resource\": \"arn:aws:iam::719261439472:role/ci\"\n },\n {\n \"Effect\": \"Allow\",\n \"Resource\": [\n \"*\"\n ],\n \"Action\": [\n \"logs:CreateLogGroup\",\n \"logs:CreateLogStream\",\n \"logs:PutLogEvents\"\n ]\n },\n {\n \"Effect\": \"Allow\",\n \"Resource\": [\n \"arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002\",\n \"arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002/*\"\n ],\n \"Action\": [\n \"s3:ListBucket\",\n \"s3:*Object\"\n ]\n }\n ]\n}\n" + } + ], + "managed_policy_arns": [ + "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser" + ], + "max_session_duration": 3600, + "name": "terra_ci_job", + "name_prefix": null, + "path": "/", + "permissions_boundary": null, + "tags": {}, + "unique_id": "AROA2O52SSXYMEA4VGINT" + }, + "after": null, + "after_unknown": {}, + "before_sensitive": { + "inline_policy": [ + {} + ], + "managed_policy_arns": [ + false + ], + "tags": {} + }, + "after_sensitive": false + } + }, + { + "address": "aws_iam_role.terra_ci_runner", + "mode": "managed", + "type": "aws_iam_role", + "name": "terra_ci_runner", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "delete" + ], + "before": { + "arn": "arn:aws:iam::719261439472:role/terra_ci_runner", + "assume_role_policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"states.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}", + "create_date": "2021-05-05T13:59:07Z", + "description": "", + "force_detach_policies": false, + "id": "terra_ci_runner", + "inline_policy": [ + { + "name": "terraform-20210505135918902900000003", + "policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"codebuild:StartBuild\",\n \"codebuild:StopBuild\",\n \"codebuild:BatchGetBuilds\"\n ],\n \"Resource\": [\n \"arn:aws:codebuild:eu-west-1:719261439472:project/terra-ci-runner\"\n ]\n },\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"events:PutTargets\",\n \"events:PutRule\",\n \"events:DescribeRule\"\n ],\n \"Resource\": [\n \"arn:aws:events:eu-west-1:719261439472:rule/StepFunctionsGetEventForCodeBuildStartBuildRule\"\n ]\n }\n ]\n}\n" + } + ], + "managed_policy_arns": [], + "max_session_duration": 3600, + "name": "terra_ci_runner", + "name_prefix": null, + "path": "/", + "permissions_boundary": null, + "tags": {}, + "unique_id": "AROA2O52SSXYPQLY6HIHV" + }, + "after": null, + "after_unknown": {}, + "before_sensitive": { + "inline_policy": [ + {} + ], + "managed_policy_arns": [], + "tags": {} + }, + "after_sensitive": false + } + }, + { + "address": "aws_iam_role_policy.terra_ci_job", + "mode": "managed", + "type": "aws_iam_role_policy", + "name": "terra_ci_job", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "delete" + ], + "before": { + "id": "terra_ci_job:terraform-20210505135914255500000001", + "name": "terraform-20210505135914255500000001", + "name_prefix": null, + "policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": \"sts:AssumeRole\",\n \"Resource\": \"arn:aws:iam::719261439472:role/ci\"\n },\n {\n \"Effect\": \"Allow\",\n \"Resource\": [\n \"*\"\n ],\n \"Action\": [\n \"logs:CreateLogGroup\",\n \"logs:CreateLogStream\",\n \"logs:PutLogEvents\"\n ]\n },\n {\n \"Effect\": \"Allow\",\n \"Resource\": [\n \"arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002\",\n \"arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002/*\"\n ],\n \"Action\": [\n \"s3:ListBucket\",\n \"s3:*Object\"\n ]\n }\n ]\n}\n", + "role": "terra_ci_job" + }, + "after": null, + "after_unknown": {}, + "before_sensitive": {}, + "after_sensitive": false + } + }, + { + "address": "aws_iam_role_policy.terra_ci_runner", + "mode": "managed", + "type": "aws_iam_role_policy", + "name": "terra_ci_runner", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "delete" + ], + "before": { + "id": "terra_ci_runner:terraform-20210505135918902900000003", + "name": "terraform-20210505135918902900000003", + "name_prefix": null, + "policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"codebuild:StartBuild\",\n \"codebuild:StopBuild\",\n \"codebuild:BatchGetBuilds\"\n ],\n \"Resource\": [\n \"arn:aws:codebuild:eu-west-1:719261439472:project/terra-ci-runner\"\n ]\n },\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"events:PutTargets\",\n \"events:PutRule\",\n \"events:DescribeRule\"\n ],\n \"Resource\": [\n \"arn:aws:events:eu-west-1:719261439472:rule/StepFunctionsGetEventForCodeBuildStartBuildRule\"\n ]\n }\n ]\n}\n", + "role": "terra_ci_runner" + }, + "after": null, + "after_unknown": {}, + "before_sensitive": {}, + "after_sensitive": false + } + }, + { + "address": "aws_iam_role_policy_attachment.terra_ci_job_ecr_access", + "mode": "managed", + "type": "aws_iam_role_policy_attachment", + "name": "terra_ci_job_ecr_access", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "delete" + ], + "before": { + "id": "terra_ci_job-20210505135914799900000002", + "policy_arn": "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser", + "role": "terra_ci_job" + }, + "after": null, + "after_unknown": {}, + "before_sensitive": {}, + "after_sensitive": false + } + }, + { + "address": "aws_s3_bucket.terra_ci", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "terra_ci", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "delete" + ], + "before": { + "acceleration_status": "", + "acl": "private", + "arn": "arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002", + "bucket": "terra-ci-artifacts-eu-west-1-000002", + "bucket_domain_name": "terra-ci-artifacts-eu-west-1-000002.s3.amazonaws.com", + "bucket_prefix": null, + "bucket_regional_domain_name": "terra-ci-artifacts-eu-west-1-000002.s3.eu-west-1.amazonaws.com", + "cors_rule": [], + "force_destroy": false, + "grant": [], + "hosted_zone_id": "Z1BKCTXD74EZPE", + "id": "terra-ci-artifacts-eu-west-1-000002", + "lifecycle_rule": [], + "logging": [], + "object_lock_configuration": [], + "policy": null, + "region": "eu-west-1", + "replication_configuration": [], + "request_payer": "BucketOwner", + "server_side_encryption_configuration": [ + { + "rule": [ + { + "apply_server_side_encryption_by_default": [ + { + "kms_master_key_id": "", + "sse_algorithm": "aws:kms" + } + ], + "bucket_key_enabled": false + } + ] + } + ], + "tags": {}, + "versioning": [ + { + "enabled": false, + "mfa_delete": false + } + ], + "website": [], + "website_domain": null, + "website_endpoint": null + }, + "after": null, + "after_unknown": {}, + "before_sensitive": { + "cors_rule": [], + "grant": [], + "lifecycle_rule": [], + "logging": [], + "object_lock_configuration": [], + "replication_configuration": [], + "server_side_encryption_configuration": [ + { + "rule": [ + { + "apply_server_side_encryption_by_default": [ + {} + ] + } + ] + } + ], + "tags": {}, + "versioning": [ + {} + ], + "website": [] + }, + "after_sensitive": false + } + }, + { + "address": "aws_sfn_state_machine.terra_ci_runner", + "mode": "managed", + "type": "aws_sfn_state_machine", + "name": "terra_ci_runner", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "delete" + ], + "before": { + "arn": "arn:aws:states:eu-west-1:719261439472:stateMachine:terra-ci-runner", + "creation_date": "2021-05-05T13:59:28Z", + "definition": "{\n \"Comment\": \"Run Terragrunt Jobs\",\n \"StartAt\": \"OnBranch?\",\n \"States\": {\n \"OnBranch?\": {\n \"Type\": \"Choice\",\n \"Choices\": [\n {\n \"Variable\": \"$.build.sourceversion\",\n \"IsPresent\": true,\n \"Next\": \"PlanBranch\"\n }\n ],\n \"Default\": \"Plan\"\n },\n \"Plan\": {\n \"Type\": \"Task\",\n \"Resource\": \"arn:aws:states:::codebuild:startBuild.sync\",\n \"Parameters\": {\n \"ProjectName\": \"terra-ci-runner\",\n \"EnvironmentVariablesOverride\": [\n {\n \"Name\": \"TERRA_CI_BUILD_NAME\",\n \"Value.$\": \"$$.Execution.Name\"\n },\n {\n \"Name\": \"TERRA_CI_RESOURCE\",\n \"Value.$\": \"$.build.environment.terra_ci_resource\"\n }\n ]\n },\n \"End\": true\n },\n \"PlanBranch\": {\n \"Type\": \"Task\",\n \"Resource\": \"arn:aws:states:::codebuild:startBuild.sync\",\n \"Parameters\": {\n \"ProjectName\": \"terra-ci-runner\",\n \"SourceVersion.$\": \"$.build.sourceversion\",\n \"EnvironmentVariablesOverride\": [\n {\n \"Name\": \"TERRA_CI_RESOURCE\",\n \"Value.$\": \"$.build.environment.terra_ci_resource\"\n }\n ]\n },\n \"End\": true\n }\n }\n}\n", + "id": "arn:aws:states:eu-west-1:719261439472:stateMachine:terra-ci-runner", + "logging_configuration": [ + { + "include_execution_data": false, + "level": "OFF", + "log_destination": "" + } + ], + "name": "terra-ci-runner", + "role_arn": "arn:aws:iam::719261439472:role/terra_ci_runner", + "status": "ACTIVE", + "tags": {}, + "type": "STANDARD" + }, + "after": null, + "after_unknown": {}, + "before_sensitive": { + "logging_configuration": [ + {} + ], + "tags": {} + }, + "after_sensitive": false + } + } + ], + "prior_state": { + "format_version": "0.1", + "terraform_version": "0.15.0", + "values": { + "root_module": { + "resources": [ + { + "address": "aws_codebuild_project.terra_ci", + "mode": "managed", + "type": "aws_codebuild_project", + "name": "terra_ci", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "arn": "arn:aws:codebuild:eu-west-1:719261439472:project/terra-ci-runner", + "artifacts": [ + { + "artifact_identifier": "", + "encryption_disabled": false, + "location": "terra-ci-artifacts-eu-west-1-000002", + "name": "terra-ci-runner", + "namespace_type": "NONE", + "override_artifact_name": false, + "packaging": "NONE", + "path": "", + "type": "S3" + } + ], + "badge_enabled": false, + "badge_url": "", + "build_timeout": 10, + "cache": [ + { + "location": "", + "modes": [], + "type": "NO_CACHE" + } + ], + "description": "Deploy environment configuration", + "encryption_key": "arn:aws:kms:eu-west-1:719261439472:alias/aws/s3", + "environment": [ + { + "certificate": "", + "compute_type": "BUILD_GENERAL1_SMALL", + "environment_variable": [], + "image": "aws/codebuild/amazonlinux2-x86_64-standard:2.0", + "image_pull_credentials_type": "CODEBUILD", + "privileged_mode": false, + "registry_credential": [], + "type": "LINUX_CONTAINER" + } + ], + "id": "arn:aws:codebuild:eu-west-1:719261439472:project/terra-ci-runner", + "logs_config": [ + { + "cloudwatch_logs": [ + { + "group_name": "", + "status": "ENABLED", + "stream_name": "" + } + ], + "s3_logs": [ + { + "encryption_disabled": false, + "location": "", + "status": "DISABLED" + } + ] + } + ], + "name": "terra-ci-runner", + "queued_timeout": 480, + "secondary_artifacts": [], + "secondary_sources": [], + "service_role": "arn:aws:iam::719261439472:role/terra_ci_job", + "source": [ + { + "auth": [], + "buildspec": "version: 0.2\nphases:\n install:\n commands:\n - make install_tools\n build:\n commands:\n - make plan_local resource=$TERRA_CI_RESOURCE\nartifacts:\n files:\n - ./tfplan\n name: $TERRA_CI_BUILD_NAME\n\n", + "git_clone_depth": 1, + "git_submodules_config": [], + "insecure_ssl": false, + "location": "https://github.com/p0tr3c-terraform/terra-ci-single-account.git", + "report_build_status": false, + "type": "GITHUB" + } + ], + "source_version": "", + "tags": {}, + "vpc_config": [] + }, + "depends_on": [ + "aws_iam_role.terra_ci_job", + "aws_s3_bucket.terra_ci", + "data.template_file.terra_ci" + ] + }, + { + "address": "aws_iam_role.terra_ci_job", + "mode": "managed", + "type": "aws_iam_role", + "name": "terra_ci_job", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "arn": "arn:aws:iam::719261439472:role/terra_ci_job", + "assume_role_policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"codebuild.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}", + "create_date": "2021-05-05T13:59:07Z", + "description": "", + "force_detach_policies": false, + "id": "terra_ci_job", + "inline_policy": [ + { + "name": "terraform-20210505135914255500000001", + "policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": \"sts:AssumeRole\",\n \"Resource\": \"arn:aws:iam::719261439472:role/ci\"\n },\n {\n \"Effect\": \"Allow\",\n \"Resource\": [\n \"*\"\n ],\n \"Action\": [\n \"logs:CreateLogGroup\",\n \"logs:CreateLogStream\",\n \"logs:PutLogEvents\"\n ]\n },\n {\n \"Effect\": \"Allow\",\n \"Resource\": [\n \"arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002\",\n \"arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002/*\"\n ],\n \"Action\": [\n \"s3:ListBucket\",\n \"s3:*Object\"\n ]\n }\n ]\n}\n" + } + ], + "managed_policy_arns": [ + "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser" + ], + "max_session_duration": 3600, + "name": "terra_ci_job", + "name_prefix": null, + "path": "/", + "permissions_boundary": null, + "tags": {}, + "unique_id": "AROA2O52SSXYMEA4VGINT" + } + }, + { + "address": "aws_iam_role.terra_ci_runner", + "mode": "managed", + "type": "aws_iam_role", + "name": "terra_ci_runner", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "arn": "arn:aws:iam::719261439472:role/terra_ci_runner", + "assume_role_policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"states.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}", + "create_date": "2021-05-05T13:59:07Z", + "description": "", + "force_detach_policies": false, + "id": "terra_ci_runner", + "inline_policy": [ + { + "name": "terraform-20210505135918902900000003", + "policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"codebuild:StartBuild\",\n \"codebuild:StopBuild\",\n \"codebuild:BatchGetBuilds\"\n ],\n \"Resource\": [\n \"arn:aws:codebuild:eu-west-1:719261439472:project/terra-ci-runner\"\n ]\n },\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"events:PutTargets\",\n \"events:PutRule\",\n \"events:DescribeRule\"\n ],\n \"Resource\": [\n \"arn:aws:events:eu-west-1:719261439472:rule/StepFunctionsGetEventForCodeBuildStartBuildRule\"\n ]\n }\n ]\n}\n" + } + ], + "managed_policy_arns": [], + "max_session_duration": 3600, + "name": "terra_ci_runner", + "name_prefix": null, + "path": "/", + "permissions_boundary": null, + "tags": {}, + "unique_id": "AROA2O52SSXYPQLY6HIHV" + } + }, + { + "address": "aws_iam_role_policy.terra_ci_job", + "mode": "managed", + "type": "aws_iam_role_policy", + "name": "terra_ci_job", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "id": "terra_ci_job:terraform-20210505135914255500000001", + "name": "terraform-20210505135914255500000001", + "name_prefix": null, + "policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": \"sts:AssumeRole\",\n \"Resource\": \"arn:aws:iam::719261439472:role/ci\"\n },\n {\n \"Effect\": \"Allow\",\n \"Resource\": [\n \"*\"\n ],\n \"Action\": [\n \"logs:CreateLogGroup\",\n \"logs:CreateLogStream\",\n \"logs:PutLogEvents\"\n ]\n },\n {\n \"Effect\": \"Allow\",\n \"Resource\": [\n \"arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002\",\n \"arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002/*\"\n ],\n \"Action\": [\n \"s3:ListBucket\",\n \"s3:*Object\"\n ]\n }\n ]\n}\n", + "role": "terra_ci_job" + }, + "depends_on": [ + "aws_iam_role.terra_ci_job", + "aws_s3_bucket.terra_ci", + "data.aws_caller_identity.current" + ] + }, + { + "address": "aws_iam_role_policy.terra_ci_runner", + "mode": "managed", + "type": "aws_iam_role_policy", + "name": "terra_ci_runner", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "id": "terra_ci_runner:terraform-20210505135918902900000003", + "name": "terraform-20210505135918902900000003", + "name_prefix": null, + "policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"codebuild:StartBuild\",\n \"codebuild:StopBuild\",\n \"codebuild:BatchGetBuilds\"\n ],\n \"Resource\": [\n \"arn:aws:codebuild:eu-west-1:719261439472:project/terra-ci-runner\"\n ]\n },\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"events:PutTargets\",\n \"events:PutRule\",\n \"events:DescribeRule\"\n ],\n \"Resource\": [\n \"arn:aws:events:eu-west-1:719261439472:rule/StepFunctionsGetEventForCodeBuildStartBuildRule\"\n ]\n }\n ]\n}\n", + "role": "terra_ci_runner" + }, + "depends_on": [ + "aws_codebuild_project.terra_ci", + "aws_iam_role.terra_ci_job", + "aws_iam_role.terra_ci_runner", + "aws_s3_bucket.terra_ci", + "data.aws_caller_identity.current", + "data.template_file.terra_ci" + ] + }, + { + "address": "aws_iam_role_policy_attachment.terra_ci_job_ecr_access", + "mode": "managed", + "type": "aws_iam_role_policy_attachment", + "name": "terra_ci_job_ecr_access", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "id": "terra_ci_job-20210505135914799900000002", + "policy_arn": "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser", + "role": "terra_ci_job" + }, + "depends_on": [ + "aws_iam_role.terra_ci_job" + ] + }, + { + "address": "aws_s3_bucket.terra_ci", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "terra_ci", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "acceleration_status": "", + "acl": "private", + "arn": "arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002", + "bucket": "terra-ci-artifacts-eu-west-1-000002", + "bucket_domain_name": "terra-ci-artifacts-eu-west-1-000002.s3.amazonaws.com", + "bucket_prefix": null, + "bucket_regional_domain_name": "terra-ci-artifacts-eu-west-1-000002.s3.eu-west-1.amazonaws.com", + "cors_rule": [], + "force_destroy": false, + "grant": [], + "hosted_zone_id": "Z1BKCTXD74EZPE", + "id": "terra-ci-artifacts-eu-west-1-000002", + "lifecycle_rule": [], + "logging": [], + "object_lock_configuration": [], + "policy": null, + "region": "eu-west-1", + "replication_configuration": [], + "request_payer": "BucketOwner", + "server_side_encryption_configuration": [ + { + "rule": [ + { + "apply_server_side_encryption_by_default": [ + { + "kms_master_key_id": "", + "sse_algorithm": "aws:kms" + } + ], + "bucket_key_enabled": false + } + ] + } + ], + "tags": {}, + "versioning": [ + { + "enabled": false, + "mfa_delete": false + } + ], + "website": [], + "website_domain": null, + "website_endpoint": null + } + }, + { + "address": "aws_sfn_state_machine.terra_ci_runner", + "mode": "managed", + "type": "aws_sfn_state_machine", + "name": "terra_ci_runner", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "arn": "arn:aws:states:eu-west-1:719261439472:stateMachine:terra-ci-runner", + "creation_date": "2021-05-05T13:59:28Z", + "definition": "{\n \"Comment\": \"Run Terragrunt Jobs\",\n \"StartAt\": \"OnBranch?\",\n \"States\": {\n \"OnBranch?\": {\n \"Type\": \"Choice\",\n \"Choices\": [\n {\n \"Variable\": \"$.build.sourceversion\",\n \"IsPresent\": true,\n \"Next\": \"PlanBranch\"\n }\n ],\n \"Default\": \"Plan\"\n },\n \"Plan\": {\n \"Type\": \"Task\",\n \"Resource\": \"arn:aws:states:::codebuild:startBuild.sync\",\n \"Parameters\": {\n \"ProjectName\": \"terra-ci-runner\",\n \"EnvironmentVariablesOverride\": [\n {\n \"Name\": \"TERRA_CI_BUILD_NAME\",\n \"Value.$\": \"$$.Execution.Name\"\n },\n {\n \"Name\": \"TERRA_CI_RESOURCE\",\n \"Value.$\": \"$.build.environment.terra_ci_resource\"\n }\n ]\n },\n \"End\": true\n },\n \"PlanBranch\": {\n \"Type\": \"Task\",\n \"Resource\": \"arn:aws:states:::codebuild:startBuild.sync\",\n \"Parameters\": {\n \"ProjectName\": \"terra-ci-runner\",\n \"SourceVersion.$\": \"$.build.sourceversion\",\n \"EnvironmentVariablesOverride\": [\n {\n \"Name\": \"TERRA_CI_RESOURCE\",\n \"Value.$\": \"$.build.environment.terra_ci_resource\"\n }\n ]\n },\n \"End\": true\n }\n }\n}\n", + "id": "arn:aws:states:eu-west-1:719261439472:stateMachine:terra-ci-runner", + "logging_configuration": [ + { + "include_execution_data": false, + "level": "OFF", + "log_destination": "" + } + ], + "name": "terra-ci-runner", + "role_arn": "arn:aws:iam::719261439472:role/terra_ci_runner", + "status": "ACTIVE", + "tags": {}, + "type": "STANDARD" + }, + "depends_on": [ + "aws_iam_role.terra_ci_job", + "aws_iam_role.terra_ci_runner", + "aws_s3_bucket.terra_ci", + "data.template_file.terra_ci", + "aws_codebuild_project.terra_ci" + ] + }, + { + "address": "data.aws_caller_identity.current", + "mode": "data", + "type": "aws_caller_identity", + "name": "current", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "account_id": "719261439472", + "arn": "arn:aws:sts::719261439472:assumed-role/ci/1620223184813487213", + "id": "719261439472", + "user_id": "AROA2O52SSXYLVFYURBIV:1620223184813487213" + } + }, + { + "address": "data.template_file.terra_ci", + "mode": "data", + "type": "template_file", + "name": "terra_ci", + "provider_name": "registry.terraform.io/hashicorp/template", + "schema_version": 0, + "values": { + "filename": null, + "id": "64e36ed71e7270140dde96fec9c89d1d55ae5a6e91f7c0be15170200dcf9481b", + "rendered": "version: 0.2\nphases:\n install:\n commands:\n - make install_tools\n build:\n commands:\n - make plan_local resource=$TERRA_CI_RESOURCE\nartifacts:\n files:\n - ./tfplan\n name: $TERRA_CI_BUILD_NAME\n\n", + "template": "version: 0.2\nphases:\n install:\n commands:\n - make install_tools\n build:\n commands:\n - make plan_local resource=$TERRA_CI_RESOURCE\nartifacts:\n files:\n - ./tfplan\n name: $TERRA_CI_BUILD_NAME\n\n", + "vars": null + } + } + ] + } + } + }, + "configuration": { + "provider_config": { + "aws": { + "name": "aws", + "expressions": { + "allowed_account_ids": { + "constant_value": [ + "719261439472" + ] + }, + "assume_role": [ + { + "role_arn": { + "constant_value": "arn:aws:iam::719261439472:role/ci" + } + } + ], + "region": { + "constant_value": "eu-west-1" + } + } + } + }, + "root_module": { + "resources": [ + { + "address": "aws_codebuild_project.terra_ci", + "mode": "managed", + "type": "aws_codebuild_project", + "name": "terra_ci", + "provider_config_key": "aws", + "expressions": { + "artifacts": [ + { + "location": { + "references": [ + "aws_s3_bucket.terra_ci" + ] + }, + "type": { + "constant_value": "S3" + } + } + ], + "build_timeout": { + "constant_value": "10" + }, + "description": { + "constant_value": "Deploy environment configuration" + }, + "environment": [ + { + "compute_type": { + "constant_value": "BUILD_GENERAL1_SMALL" + }, + "image": { + "constant_value": "aws/codebuild/amazonlinux2-x86_64-standard:2.0" + }, + "image_pull_credentials_type": { + "constant_value": "CODEBUILD" + }, + "privileged_mode": { + "constant_value": false + }, + "type": { + "constant_value": "LINUX_CONTAINER" + } + } + ], + "logs_config": [ + { + "cloudwatch_logs": [ + { + "status": { + "constant_value": "ENABLED" + } + } + ], + "s3_logs": [ + { + "encryption_disabled": { + "constant_value": false + }, + "status": { + "constant_value": "DISABLED" + } + } + ] + } + ], + "name": { + "constant_value": "terra-ci-runner" + }, + "service_role": { + "references": [ + "aws_iam_role.terra_ci_job" + ] + }, + "source": [ + { + "buildspec": { + "references": [ + "data.template_file.terra_ci" + ] + }, + "git_clone_depth": { + "constant_value": 1 + }, + "insecure_ssl": { + "constant_value": false + }, + "location": { + "references": [ + "var.repo_url" + ] + }, + "report_build_status": { + "constant_value": false + }, + "type": { + "constant_value": "GITHUB" + } + } + ] + }, + "schema_version": 0 + }, + { + "address": "aws_iam_role.terra_ci_job", + "mode": "managed", + "type": "aws_iam_role", + "name": "terra_ci_job", + "provider_config_key": "aws", + "expressions": { + "assume_role_policy": { + "constant_value": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Principal\": {\n \"Service\": \"codebuild.amazonaws.com\"\n },\n \"Action\": \"sts:AssumeRole\"\n }\n ]\n}\n" + }, + "name": { + "constant_value": "terra_ci_job" + } + }, + "schema_version": 0 + }, + { + "address": "aws_iam_role.terra_ci_runner", + "mode": "managed", + "type": "aws_iam_role", + "name": "terra_ci_runner", + "provider_config_key": "aws", + "expressions": { + "assume_role_policy": { + "constant_value": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Principal\": {\n \"Service\": \"states.amazonaws.com\"\n },\n \"Action\": \"sts:AssumeRole\"\n }\n ]\n}\n" + }, + "name": { + "constant_value": "terra_ci_runner" + } + }, + "schema_version": 0 + }, + { + "address": "aws_iam_role_policy.terra_ci_job", + "mode": "managed", + "type": "aws_iam_role_policy", + "name": "terra_ci_job", + "provider_config_key": "aws", + "expressions": { + "policy": { + "references": [ + "data.aws_caller_identity.current", + "aws_s3_bucket.terra_ci", + "aws_s3_bucket.terra_ci" + ] + }, + "role": { + "references": [ + "aws_iam_role.terra_ci_job" + ] + } + }, + "schema_version": 0 + }, + { + "address": "aws_iam_role_policy.terra_ci_runner", + "mode": "managed", + "type": "aws_iam_role_policy", + "name": "terra_ci_runner", + "provider_config_key": "aws", + "expressions": { + "policy": { + "references": [ + "aws_codebuild_project.terra_ci", + "var.aws_region", + "data.aws_caller_identity.current" + ] + }, + "role": { + "references": [ + "aws_iam_role.terra_ci_runner" + ] + } + }, + "schema_version": 0 + }, + { + "address": "aws_iam_role_policy_attachment.terra_ci_job_ecr_access", + "mode": "managed", + "type": "aws_iam_role_policy_attachment", + "name": "terra_ci_job_ecr_access", + "provider_config_key": "aws", + "expressions": { + "policy_arn": { + "constant_value": "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser" + }, + "role": { + "references": [ + "aws_iam_role.terra_ci_job" + ] + } + }, + "schema_version": 0 + }, + { + "address": "aws_s3_bucket.terra_ci", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "terra_ci", + "provider_config_key": "aws", + "expressions": { + "acl": { + "constant_value": "private" + }, + "bucket": { + "references": [ + "var.aws_region", + "var.serial_number" + ] + }, + "server_side_encryption_configuration": [ + { + "rule": [ + { + "apply_server_side_encryption_by_default": [ + { + "sse_algorithm": { + "constant_value": "aws:kms" + } + } + ], + "bucket_key_enabled": { + "constant_value": false + } + } + ] + } + ] + }, + "schema_version": 0 + }, + { + "address": "aws_sfn_state_machine.terra_ci_runner", + "mode": "managed", + "type": "aws_sfn_state_machine", + "name": "terra_ci_runner", + "provider_config_key": "aws", + "expressions": { + "definition": { + "references": [ + "aws_codebuild_project.terra_ci", + "aws_codebuild_project.terra_ci" + ] + }, + "name": { + "constant_value": "terra-ci-runner" + }, + "role_arn": { + "references": [ + "aws_iam_role.terra_ci_runner" + ] + } + }, + "schema_version": 0 + }, + { + "address": "data.aws_caller_identity.current", + "mode": "data", + "type": "aws_caller_identity", + "name": "current", + "provider_config_key": "aws", + "schema_version": 0 + }, + { + "address": "data.template_file.terra_ci", + "mode": "data", + "type": "template_file", + "name": "terra_ci", + "provider_config_key": "template", + "expressions": { + "template": {} + }, + "schema_version": 0 + } + ], + "variables": { + "aws_region": {}, + "repo_url": {}, + "serial_number": {} + } + } + } + } diff --git a/tests/test_files/tf_content_generator_files/tfplan-false-var/tf_content.txt b/tests/test_files/tf_content_generator_files/tfplan-false-var/tf_content.txt new file mode 100644 index 00000000..5d902651 --- /dev/null +++ b/tests/test_files/tf_content_generator_files/tfplan-false-var/tf_content.txt @@ -0,0 +1,14 @@ +resource "aws_s3_bucket" "efrat-env-var-test" { + bucket = "efrat-env-var-test" + force_destroy = false + tags = null + timeouts = null +} + +resource "aws_s3_bucket_public_access_block" "efrat-env-var-test" { + block_public_acls = false + block_public_policy = true + ignore_public_acls = false + restrict_public_buckets = true +} + diff --git a/tests/test_files/tf_content_generator_files/tfplan-false-var/tfplan.json b/tests/test_files/tf_content_generator_files/tfplan-false-var/tfplan.json new file mode 100644 index 00000000..2c8d033c --- /dev/null +++ b/tests/test_files/tf_content_generator_files/tfplan-false-var/tfplan.json @@ -0,0 +1,218 @@ +{ + "format_version": "1.2", + "terraform_version": "1.5.4", + "variables": { + "IT_IS_FALSE": { + "value": "false" + } + }, + "planned_values": { + "root_module": { + "resources": [ + { + "address": "aws_s3_bucket.efrat-env-var-test", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "efrat-env-var-test", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "bucket": "efrat-env-var-test", + "force_destroy": false, + "tags": null, + "timeouts": null + }, + "sensitive_values": { + "cors_rule": [], + "grant": [], + "lifecycle_rule": [], + "logging": [], + "object_lock_configuration": [], + "replication_configuration": [], + "server_side_encryption_configuration": [], + "tags_all": {}, + "versioning": [], + "website": [] + } + }, + { + "address": "aws_s3_bucket_public_access_block.efrat-env-var-test", + "mode": "managed", + "type": "aws_s3_bucket_public_access_block", + "name": "efrat-env-var-test", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "block_public_acls": false, + "block_public_policy": true, + "ignore_public_acls": false, + "restrict_public_buckets": true + }, + "sensitive_values": {} + } + ] + } + }, + "resource_changes": [ + { + "address": "aws_s3_bucket.efrat-env-var-test", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "efrat-env-var-test", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "bucket": "efrat-env-var-test", + "force_destroy": false, + "tags": null, + "timeouts": null + }, + "after_unknown": { + "acceleration_status": true, + "acl": true, + "arn": true, + "bucket_domain_name": true, + "bucket_prefix": true, + "bucket_regional_domain_name": true, + "cors_rule": true, + "grant": true, + "hosted_zone_id": true, + "id": true, + "lifecycle_rule": true, + "logging": true, + "object_lock_configuration": true, + "object_lock_enabled": true, + "policy": true, + "region": true, + "replication_configuration": true, + "request_payer": true, + "server_side_encryption_configuration": true, + "tags_all": true, + "versioning": true, + "website": true, + "website_domain": true, + "website_endpoint": true + }, + "before_sensitive": false, + "after_sensitive": { + "cors_rule": [], + "grant": [], + "lifecycle_rule": [], + "logging": [], + "object_lock_configuration": [], + "replication_configuration": [], + "server_side_encryption_configuration": [], + "tags_all": {}, + "versioning": [], + "website": [] + } + } + }, + { + "address": "aws_s3_bucket_public_access_block.efrat-env-var-test", + "mode": "managed", + "type": "aws_s3_bucket_public_access_block", + "name": "efrat-env-var-test", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "block_public_acls": false, + "block_public_policy": true, + "ignore_public_acls": false, + "restrict_public_buckets": true + }, + "after_unknown": { + "bucket": true, + "id": true + }, + "before_sensitive": false, + "after_sensitive": {} + } + } + ], + "configuration": { + "provider_config": { + "aws": { + "name": "aws", + "full_name": "registry.terraform.io/hashicorp/aws", + "expressions": { + "profile": { + "constant_value": "efrat" + }, + "region": { + "constant_value": "us-east-1" + } + } + } + }, + "root_module": { + "resources": [ + { + "address": "aws_s3_bucket.efrat-env-var-test", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "efrat-env-var-test", + "provider_config_key": "aws", + "expressions": { + "bucket": { + "constant_value": "efrat-env-var-test" + } + }, + "schema_version": 0 + }, + { + "address": "aws_s3_bucket_public_access_block.efrat-env-var-test", + "mode": "managed", + "type": "aws_s3_bucket_public_access_block", + "name": "efrat-env-var-test", + "provider_config_key": "aws", + "expressions": { + "block_public_acls": { + "references": [ + "var.IT_IS_FALSE" + ] + }, + "block_public_policy": { + "constant_value": true + }, + "bucket": { + "references": [ + "aws_s3_bucket.efrat-env-var-test.id", + "aws_s3_bucket.efrat-env-var-test" + ] + }, + "ignore_public_acls": { + "constant_value": false + }, + "restrict_public_buckets": { + "constant_value": true + } + }, + "schema_version": 0 + } + ], + "variables": { + "IT_IS_FALSE": { + "description": "This is an example input variable using env variables." + } + } + } + }, + "relevant_attributes": [ + { + "resource": "aws_s3_bucket.efrat-env-var-test", + "attribute": [ + "id" + ] + } + ], + "timestamp": "2023-07-31T17:54:18Z" +} \ No newline at end of file diff --git a/tests/test_files/tf_content_generator_files/tfplan-no-op-example/tf_content.txt b/tests/test_files/tf_content_generator_files/tfplan-no-op-example/tf_content.txt new file mode 100644 index 00000000..be9b8bca --- /dev/null +++ b/tests/test_files/tf_content_generator_files/tfplan-no-op-example/tf_content.txt @@ -0,0 +1,122 @@ +resource "aws_codebuild_project" "terra_ci" { + arn = "arn:aws:codebuild:eu-west-1:719261439472:project/terra-ci-runner" + artifacts = [{"artifact_identifier": "", "encryption_disabled": false, "location": "terra-ci-artifacts-eu-west-1-000002", "name": "terra-ci-runner", "namespace_type": "NONE", "override_artifact_name": false, "packaging": "NONE", "path": "", "type": "S3"}] + badge_enabled = false + badge_url = "" + build_timeout = 10 + cache = [{"location": "", "modes": [], "type": "NO_CACHE"}] + description = "Deploy environment configuration" + encryption_key = "arn:aws:kms:eu-west-1:719261439472:alias/aws/s3" + environment = [{"certificate": "", "compute_type": "BUILD_GENERAL1_SMALL", "environment_variable": [], "image": "aws/codebuild/amazonlinux2-x86_64-standard:2.0", "image_pull_credentials_type": "CODEBUILD", "privileged_mode": false, "registry_credential": [], "type": "LINUX_CONTAINER"}] + id = "arn:aws:codebuild:eu-west-1:719261439472:project/terra-ci-runner" + logs_config = [{"cloudwatch_logs": [{"group_name": "", "status": "ENABLED", "stream_name": ""}], "s3_logs": [{"encryption_disabled": false, "location": "", "status": "DISABLED"}]}] + name = "terra-ci-runner" + queued_timeout = 480 + secondary_artifacts = [] + secondary_sources = [] + service_role = "arn:aws:iam::719261439472:role/terra_ci_job" + source = [{"auth": [], "buildspec": "version: 0.2\nphases:\n install:\n commands:\n - make install_tools\n build:\n commands:\n - make plan_local resource=$TERRA_CI_RESOURCE\nartifacts:\n files:\n - ./tfplan\n name: $TERRA_CI_BUILD_NAME\n\n", "git_clone_depth": 1, "git_submodules_config": [], "insecure_ssl": false, "location": "https://github.com/p0tr3c-terraform/terra-ci-single-account.git", "report_build_status": false, "type": "GITHUB"}] + source_version = "" + tags = {} + vpc_config = [] +} + +resource "aws_iam_role" "terra_ci_job" { + arn = "arn:aws:iam::719261439472:role/terra_ci_job" + assume_role_policy = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"codebuild.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}" + create_date = "2021-05-01T15:08:15Z" + description = "" + force_detach_policies = false + id = "terra_ci_job" + inline_policy = [{"name": "terraform-20210501150816628700000001", "policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": \"sts:AssumeRole\",\n \"Resource\": \"arn:aws:iam::719261439472:role/ci\"\n },\n {\n \"Effect\": \"Allow\",\n \"Resource\": [\n \"*\"\n ],\n \"Action\": [\n \"logs:CreateLogGroup\",\n \"logs:CreateLogStream\",\n \"logs:PutLogEvents\"\n ]\n },\n {\n \"Effect\": \"Allow\",\n \"Resource\": [\n \"arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002\",\n \"arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002/*\"\n ],\n \"Action\": [\n \"s3:ListBucket\",\n \"s3:*Object\"\n ]\n }\n ]\n}\n"}] + managed_policy_arns = ["arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser"] + max_session_duration = 3600 + name = "terra_ci_job" + name_prefix = null + path = "/" + permissions_boundary = null + tags = {} + unique_id = "AROA2O52SSXYL7LBSM733" +} + +resource "aws_iam_role" "terra_ci_runner" { + arn = "arn:aws:iam::719261439472:role/terra_ci_runner" + assume_role_policy = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"states.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}" + create_date = "2021-05-01T15:08:15Z" + description = "" + force_detach_policies = false + id = "terra_ci_runner" + inline_policy = [{"name": "terraform-20210501150825425000000003", "policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"codebuild:StartBuild\",\n \"codebuild:StopBuild\",\n \"codebuild:BatchGetBuilds\"\n ],\n \"Resource\": [\n \"arn:aws:codebuild:eu-west-1:719261439472:project/terra-ci-runner\"\n ]\n },\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"events:PutTargets\",\n \"events:PutRule\",\n \"events:DescribeRule\"\n ],\n \"Resource\": [\n \"arn:aws:events:eu-west-1:719261439472:rule/StepFunctionsGetEventForCodeBuildStartBuildRule\"\n ]\n }\n ]\n}\n"}] + managed_policy_arns = [] + max_session_duration = 3600 + name = "terra_ci_runner" + name_prefix = null + path = "/" + permissions_boundary = null + tags = {} + unique_id = "AROA2O52SSXYDBYYTG4OB" +} + +resource "aws_iam_role_policy" "terra_ci_job" { + id = "terra_ci_job:terraform-20210501150816628700000001" + name = "terraform-20210501150816628700000001" + name_prefix = null + policy = "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": \"sts:AssumeRole\",\n \"Resource\": \"arn:aws:iam::719261439472:role/ci\"\n },\n {\n \"Effect\": \"Allow\",\n \"Resource\": [\n \"*\"\n ],\n \"Action\": [\n \"logs:CreateLogGroup\",\n \"logs:CreateLogStream\",\n \"logs:PutLogEvents\"\n ]\n },\n {\n \"Effect\": \"Allow\",\n \"Resource\": [\n \"arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002\",\n \"arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002/*\"\n ],\n \"Action\": [\n \"s3:ListBucket\",\n \"s3:*Object\"\n ]\n }\n ]\n}\n" + role = "terra_ci_job" +} + +resource "aws_iam_role_policy" "terra_ci_runner" { + id = "terra_ci_runner:terraform-20210501150825425000000003" + name = "terraform-20210501150825425000000003" + name_prefix = null + policy = "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"codebuild:StartBuild\",\n \"codebuild:StopBuild\",\n \"codebuild:BatchGetBuilds\"\n ],\n \"Resource\": [\n \"arn:aws:codebuild:eu-west-1:719261439472:project/terra-ci-runner\"\n ]\n },\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"events:PutTargets\",\n \"events:PutRule\",\n \"events:DescribeRule\"\n ],\n \"Resource\": [\n \"arn:aws:events:eu-west-1:719261439472:rule/StepFunctionsGetEventForCodeBuildStartBuildRule\"\n ]\n }\n ]\n}\n" + role = "terra_ci_runner" +} + +resource "aws_iam_role_policy_attachment" "terra_ci_job_ecr_access" { + id = "terra_ci_job-20210501150817089800000002" + policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser" + role = "terra_ci_job" +} + +resource "aws_s3_bucket" "terra_ci" { + acceleration_status = "" + acl = "private" + arn = "arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002" + bucket = "terra-ci-artifacts-eu-west-1-000002" + bucket_domain_name = "terra-ci-artifacts-eu-west-1-000002.s3.amazonaws.com" + bucket_prefix = null + bucket_regional_domain_name = "terra-ci-artifacts-eu-west-1-000002.s3.eu-west-1.amazonaws.com" + cors_rule = [] + force_destroy = false + grant = [] + hosted_zone_id = "Z1BKCTXD74EZPE" + id = "terra-ci-artifacts-eu-west-1-000002" + lifecycle_rule = [] + logging = [] + object_lock_configuration = [] + policy = null + region = "eu-west-1" + replication_configuration = [] + request_payer = "BucketOwner" + server_side_encryption_configuration = [{"rule": [{"apply_server_side_encryption_by_default": [{"kms_master_key_id": "", "sse_algorithm": "aws:kms"}], "bucket_key_enabled": false}]}] + tags = {} + versioning = [{"enabled": false, "mfa_delete": false}] + website = [] + website_domain = null + website_endpoint = null +} + +resource "aws_sfn_state_machine" "terra_ci_runner" { + arn = "arn:aws:states:eu-west-1:719261439472:stateMachine:terra-ci-runner" + creation_date = "2021-05-01T15:09:28Z" + definition = "{\n \"Comment\": \"Run Terragrunt Jobs\",\n \"StartAt\": \"OnBranch?\",\n \"States\": {\n \"OnBranch?\": {\n \"Type\": \"Choice\",\n \"Choices\": [\n {\n \"Variable\": \"$.build.sourceversion\",\n \"IsPresent\": true,\n \"Next\": \"PlanBranch\"\n }\n ],\n \"Default\": \"Plan\"\n },\n \"Plan\": {\n \"Type\": \"Task\",\n \"Resource\": \"arn:aws:states:::codebuild:startBuild.sync\",\n \"Parameters\": {\n \"ProjectName\": \"terra-ci-runner\",\n \"EnvironmentVariablesOverride\": [\n {\n \"Name\": \"TERRA_CI_BUILD_NAME\",\n \"Value.$\": \"$$.Execution.Name\"\n },\n {\n \"Name\": \"TERRA_CI_RESOURCE\",\n \"Value.$\": \"$.build.environment.terra_ci_resource\"\n }\n ]\n },\n \"End\": true\n },\n \"PlanBranch\": {\n \"Type\": \"Task\",\n \"Resource\": \"arn:aws:states:::codebuild:startBuild.sync\",\n \"Parameters\": {\n \"ProjectName\": \"terra-ci-runner\",\n \"SourceVersion.$\": \"$.build.sourceversion\",\n \"EnvironmentVariablesOverride\": [\n {\n \"Name\": \"TERRA_CI_RESOURCE\",\n \"Value.$\": \"$.build.environment.terra_ci_resource\"\n }\n ]\n },\n \"End\": true\n }\n }\n}\n" + id = "arn:aws:states:eu-west-1:719261439472:stateMachine:terra-ci-runner" + logging_configuration = [{"include_execution_data": false, "level": "OFF", "log_destination": ""}] + name = "terra-ci-runner" + role_arn = "arn:aws:iam::719261439472:role/terra_ci_runner" + status = "ACTIVE" + tags = {} + type = "STANDARD" +} + diff --git a/tests/test_files/tf_content_generator_files/tfplan-no-op-example/tfplan.json b/tests/test_files/tf_content_generator_files/tfplan-no-op-example/tfplan.json new file mode 100644 index 00000000..e31a7ea5 --- /dev/null +++ b/tests/test_files/tf_content_generator_files/tfplan-no-op-example/tfplan.json @@ -0,0 +1,1634 @@ +{ + "comment-for-reader": "THIS TERRAFORM FILE REPRESENTS A REAL TF-PLAN OUTPUT WHICH HAS VULNERABILITES IN IT'S CURRENT STATE, BUT NOT IN PROPOSED RESOURCE CHANGES", + "format_version": "0.1", + "terraform_version": "0.15.0", + "variables": { + "aws_region": { + "value": "eu-west-1" + }, + "repo_url": { + "value": "https://github.com/p0tr3c-terraform/terra-ci-single-account.git" + }, + "serial_number": { + "value": "000002" + } + }, + "planned_values": { + "root_module": { + "resources": [ + { + "address": "aws_codebuild_project.terra_ci", + "mode": "managed", + "type": "aws_codebuild_project", + "name": "terra_ci", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "arn": "arn:aws:codebuild:eu-west-1:719261439472:project/terra-ci-runner", + "artifacts": [ + { + "artifact_identifier": "", + "encryption_disabled": false, + "location": "terra-ci-artifacts-eu-west-1-000002", + "name": "terra-ci-runner", + "namespace_type": "NONE", + "override_artifact_name": false, + "packaging": "NONE", + "path": "", + "type": "S3" + } + ], + "badge_enabled": false, + "badge_url": "", + "build_timeout": 10, + "cache": [ + { + "location": "", + "modes": [], + "type": "NO_CACHE" + } + ], + "description": "Deploy environment configuration", + "encryption_key": "arn:aws:kms:eu-west-1:719261439472:alias/aws/s3", + "environment": [ + { + "certificate": "", + "compute_type": "BUILD_GENERAL1_SMALL", + "environment_variable": [], + "image": "aws/codebuild/amazonlinux2-x86_64-standard:2.0", + "image_pull_credentials_type": "CODEBUILD", + "privileged_mode": false, + "registry_credential": [], + "type": "LINUX_CONTAINER" + } + ], + "id": "arn:aws:codebuild:eu-west-1:719261439472:project/terra-ci-runner", + "logs_config": [ + { + "cloudwatch_logs": [ + { + "group_name": "", + "status": "ENABLED", + "stream_name": "" + } + ], + "s3_logs": [ + { + "encryption_disabled": false, + "location": "", + "status": "DISABLED" + } + ] + } + ], + "name": "terra-ci-runner", + "queued_timeout": 480, + "secondary_artifacts": [], + "secondary_sources": [], + "service_role": "arn:aws:iam::719261439472:role/terra_ci_job", + "source": [ + { + "auth": [], + "buildspec": "version: 0.2\nphases:\n install:\n commands:\n - make install_tools\n build:\n commands:\n - make plan_local resource=$TERRA_CI_RESOURCE\nartifacts:\n files:\n - ./tfplan\n name: $TERRA_CI_BUILD_NAME\n\n", + "git_clone_depth": 1, + "git_submodules_config": [], + "insecure_ssl": false, + "location": "https://github.com/p0tr3c-terraform/terra-ci-single-account.git", + "report_build_status": false, + "type": "GITHUB" + } + ], + "source_version": "", + "tags": {}, + "vpc_config": [] + } + }, + { + "address": "aws_iam_role.terra_ci_job", + "mode": "managed", + "type": "aws_iam_role", + "name": "terra_ci_job", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "arn": "arn:aws:iam::719261439472:role/terra_ci_job", + "assume_role_policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"codebuild.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}", + "create_date": "2021-05-01T15:08:15Z", + "description": "", + "force_detach_policies": false, + "id": "terra_ci_job", + "inline_policy": [ + { + "name": "terraform-20210501150816628700000001", + "policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": \"sts:AssumeRole\",\n \"Resource\": \"arn:aws:iam::719261439472:role/ci\"\n },\n {\n \"Effect\": \"Allow\",\n \"Resource\": [\n \"*\"\n ],\n \"Action\": [\n \"logs:CreateLogGroup\",\n \"logs:CreateLogStream\",\n \"logs:PutLogEvents\"\n ]\n },\n {\n \"Effect\": \"Allow\",\n \"Resource\": [\n \"arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002\",\n \"arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002/*\"\n ],\n \"Action\": [\n \"s3:ListBucket\",\n \"s3:*Object\"\n ]\n }\n ]\n}\n" + } + ], + "managed_policy_arns": [ + "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser" + ], + "max_session_duration": 3600, + "name": "terra_ci_job", + "name_prefix": null, + "path": "/", + "permissions_boundary": null, + "tags": {}, + "unique_id": "AROA2O52SSXYL7LBSM733" + } + }, + { + "address": "aws_iam_role.terra_ci_runner", + "mode": "managed", + "type": "aws_iam_role", + "name": "terra_ci_runner", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "arn": "arn:aws:iam::719261439472:role/terra_ci_runner", + "assume_role_policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"states.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}", + "create_date": "2021-05-01T15:08:15Z", + "description": "", + "force_detach_policies": false, + "id": "terra_ci_runner", + "inline_policy": [ + { + "name": "terraform-20210501150825425000000003", + "policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"codebuild:StartBuild\",\n \"codebuild:StopBuild\",\n \"codebuild:BatchGetBuilds\"\n ],\n \"Resource\": [\n \"arn:aws:codebuild:eu-west-1:719261439472:project/terra-ci-runner\"\n ]\n },\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"events:PutTargets\",\n \"events:PutRule\",\n \"events:DescribeRule\"\n ],\n \"Resource\": [\n \"arn:aws:events:eu-west-1:719261439472:rule/StepFunctionsGetEventForCodeBuildStartBuildRule\"\n ]\n }\n ]\n}\n" + } + ], + "managed_policy_arns": [], + "max_session_duration": 3600, + "name": "terra_ci_runner", + "name_prefix": null, + "path": "/", + "permissions_boundary": null, + "tags": {}, + "unique_id": "AROA2O52SSXYDBYYTG4OB" + } + }, + { + "address": "aws_iam_role_policy.terra_ci_job", + "mode": "managed", + "type": "aws_iam_role_policy", + "name": "terra_ci_job", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "id": "terra_ci_job:terraform-20210501150816628700000001", + "name": "terraform-20210501150816628700000001", + "name_prefix": null, + "policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": \"sts:AssumeRole\",\n \"Resource\": \"arn:aws:iam::719261439472:role/ci\"\n },\n {\n \"Effect\": \"Allow\",\n \"Resource\": [\n \"*\"\n ],\n \"Action\": [\n \"logs:CreateLogGroup\",\n \"logs:CreateLogStream\",\n \"logs:PutLogEvents\"\n ]\n },\n {\n \"Effect\": \"Allow\",\n \"Resource\": [\n \"arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002\",\n \"arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002/*\"\n ],\n \"Action\": [\n \"s3:ListBucket\",\n \"s3:*Object\"\n ]\n }\n ]\n}\n", + "role": "terra_ci_job" + } + }, + { + "address": "aws_iam_role_policy.terra_ci_runner", + "mode": "managed", + "type": "aws_iam_role_policy", + "name": "terra_ci_runner", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "id": "terra_ci_runner:terraform-20210501150825425000000003", + "name": "terraform-20210501150825425000000003", + "name_prefix": null, + "policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"codebuild:StartBuild\",\n \"codebuild:StopBuild\",\n \"codebuild:BatchGetBuilds\"\n ],\n \"Resource\": [\n \"arn:aws:codebuild:eu-west-1:719261439472:project/terra-ci-runner\"\n ]\n },\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"events:PutTargets\",\n \"events:PutRule\",\n \"events:DescribeRule\"\n ],\n \"Resource\": [\n \"arn:aws:events:eu-west-1:719261439472:rule/StepFunctionsGetEventForCodeBuildStartBuildRule\"\n ]\n }\n ]\n}\n", + "role": "terra_ci_runner" + } + }, + { + "address": "aws_iam_role_policy_attachment.terra_ci_job_ecr_access", + "mode": "managed", + "type": "aws_iam_role_policy_attachment", + "name": "terra_ci_job_ecr_access", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "id": "terra_ci_job-20210501150817089800000002", + "policy_arn": "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser", + "role": "terra_ci_job" + } + }, + { + "address": "aws_s3_bucket.terra_ci", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "terra_ci", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "acceleration_status": "", + "acl": "private", + "arn": "arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002", + "bucket": "terra-ci-artifacts-eu-west-1-000002", + "bucket_domain_name": "terra-ci-artifacts-eu-west-1-000002.s3.amazonaws.com", + "bucket_prefix": null, + "bucket_regional_domain_name": "terra-ci-artifacts-eu-west-1-000002.s3.eu-west-1.amazonaws.com", + "cors_rule": [], + "force_destroy": false, + "grant": [], + "hosted_zone_id": "Z1BKCTXD74EZPE", + "id": "terra-ci-artifacts-eu-west-1-000002", + "lifecycle_rule": [], + "logging": [], + "object_lock_configuration": [], + "policy": null, + "region": "eu-west-1", + "replication_configuration": [], + "request_payer": "BucketOwner", + "server_side_encryption_configuration": [ + { + "rule": [ + { + "apply_server_side_encryption_by_default": [ + { + "kms_master_key_id": "", + "sse_algorithm": "aws:kms" + } + ], + "bucket_key_enabled": false + } + ] + } + ], + "tags": {}, + "versioning": [ + { + "enabled": false, + "mfa_delete": false + } + ], + "website": [], + "website_domain": null, + "website_endpoint": null + } + }, + { + "address": "aws_sfn_state_machine.terra_ci_runner", + "mode": "managed", + "type": "aws_sfn_state_machine", + "name": "terra_ci_runner", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "arn": "arn:aws:states:eu-west-1:719261439472:stateMachine:terra-ci-runner", + "creation_date": "2021-05-01T15:09:28Z", + "definition": "{\n \"Comment\": \"Run Terragrunt Jobs\",\n \"StartAt\": \"OnBranch?\",\n \"States\": {\n \"OnBranch?\": {\n \"Type\": \"Choice\",\n \"Choices\": [\n {\n \"Variable\": \"$.build.sourceversion\",\n \"IsPresent\": true,\n \"Next\": \"PlanBranch\"\n }\n ],\n \"Default\": \"Plan\"\n },\n \"Plan\": {\n \"Type\": \"Task\",\n \"Resource\": \"arn:aws:states:::codebuild:startBuild.sync\",\n \"Parameters\": {\n \"ProjectName\": \"terra-ci-runner\",\n \"EnvironmentVariablesOverride\": [\n {\n \"Name\": \"TERRA_CI_BUILD_NAME\",\n \"Value.$\": \"$$.Execution.Name\"\n },\n {\n \"Name\": \"TERRA_CI_RESOURCE\",\n \"Value.$\": \"$.build.environment.terra_ci_resource\"\n }\n ]\n },\n \"End\": true\n },\n \"PlanBranch\": {\n \"Type\": \"Task\",\n \"Resource\": \"arn:aws:states:::codebuild:startBuild.sync\",\n \"Parameters\": {\n \"ProjectName\": \"terra-ci-runner\",\n \"SourceVersion.$\": \"$.build.sourceversion\",\n \"EnvironmentVariablesOverride\": [\n {\n \"Name\": \"TERRA_CI_RESOURCE\",\n \"Value.$\": \"$.build.environment.terra_ci_resource\"\n }\n ]\n },\n \"End\": true\n }\n }\n}\n", + "id": "arn:aws:states:eu-west-1:719261439472:stateMachine:terra-ci-runner", + "logging_configuration": [ + { + "include_execution_data": false, + "level": "OFF", + "log_destination": "" + } + ], + "name": "terra-ci-runner", + "role_arn": "arn:aws:iam::719261439472:role/terra_ci_runner", + "status": "ACTIVE", + "tags": {}, + "type": "STANDARD" + } + } + ] + } + }, + "resource_changes": [ + { + "address": "aws_codebuild_project.terra_ci", + "mode": "managed", + "type": "aws_codebuild_project", + "name": "terra_ci", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": { + "arn": "arn:aws:codebuild:eu-west-1:719261439472:project/terra-ci-runner", + "artifacts": [ + { + "artifact_identifier": "", + "encryption_disabled": false, + "location": "terra-ci-artifacts-eu-west-1-000002", + "name": "terra-ci-runner", + "namespace_type": "NONE", + "override_artifact_name": false, + "packaging": "NONE", + "path": "", + "type": "S3" + } + ], + "badge_enabled": false, + "badge_url": "", + "build_timeout": 10, + "cache": [ + { + "location": "", + "modes": [], + "type": "NO_CACHE" + } + ], + "description": "Deploy environment configuration", + "encryption_key": "arn:aws:kms:eu-west-1:719261439472:alias/aws/s3", + "environment": [ + { + "certificate": "", + "compute_type": "BUILD_GENERAL1_SMALL", + "environment_variable": [], + "image": "aws/codebuild/amazonlinux2-x86_64-standard:2.0", + "image_pull_credentials_type": "CODEBUILD", + "privileged_mode": false, + "registry_credential": [], + "type": "LINUX_CONTAINER" + } + ], + "id": "arn:aws:codebuild:eu-west-1:719261439472:project/terra-ci-runner", + "logs_config": [ + { + "cloudwatch_logs": [ + { + "group_name": "", + "status": "ENABLED", + "stream_name": "" + } + ], + "s3_logs": [ + { + "encryption_disabled": false, + "location": "", + "status": "DISABLED" + } + ] + } + ], + "name": "terra-ci-runner", + "queued_timeout": 480, + "secondary_artifacts": [], + "secondary_sources": [], + "service_role": "arn:aws:iam::719261439472:role/terra_ci_job", + "source": [ + { + "auth": [], + "buildspec": "version: 0.2\nphases:\n install:\n commands:\n - make install_tools\n build:\n commands:\n - make plan_local resource=$TERRA_CI_RESOURCE\nartifacts:\n files:\n - ./tfplan\n name: $TERRA_CI_BUILD_NAME\n\n", + "git_clone_depth": 1, + "git_submodules_config": [], + "insecure_ssl": false, + "location": "https://github.com/p0tr3c-terraform/terra-ci-single-account.git", + "report_build_status": false, + "type": "GITHUB" + } + ], + "source_version": "", + "tags": {}, + "vpc_config": [] + }, + "after": { + "arn": "arn:aws:codebuild:eu-west-1:719261439472:project/terra-ci-runner", + "artifacts": [ + { + "artifact_identifier": "", + "encryption_disabled": false, + "location": "terra-ci-artifacts-eu-west-1-000002", + "name": "terra-ci-runner", + "namespace_type": "NONE", + "override_artifact_name": false, + "packaging": "NONE", + "path": "", + "type": "S3" + } + ], + "badge_enabled": false, + "badge_url": "", + "build_timeout": 10, + "cache": [ + { + "location": "", + "modes": [], + "type": "NO_CACHE" + } + ], + "description": "Deploy environment configuration", + "encryption_key": "arn:aws:kms:eu-west-1:719261439472:alias/aws/s3", + "environment": [ + { + "certificate": "", + "compute_type": "BUILD_GENERAL1_SMALL", + "environment_variable": [], + "image": "aws/codebuild/amazonlinux2-x86_64-standard:2.0", + "image_pull_credentials_type": "CODEBUILD", + "privileged_mode": false, + "registry_credential": [], + "type": "LINUX_CONTAINER" + } + ], + "id": "arn:aws:codebuild:eu-west-1:719261439472:project/terra-ci-runner", + "logs_config": [ + { + "cloudwatch_logs": [ + { + "group_name": "", + "status": "ENABLED", + "stream_name": "" + } + ], + "s3_logs": [ + { + "encryption_disabled": false, + "location": "", + "status": "DISABLED" + } + ] + } + ], + "name": "terra-ci-runner", + "queued_timeout": 480, + "secondary_artifacts": [], + "secondary_sources": [], + "service_role": "arn:aws:iam::719261439472:role/terra_ci_job", + "source": [ + { + "auth": [], + "buildspec": "version: 0.2\nphases:\n install:\n commands:\n - make install_tools\n build:\n commands:\n - make plan_local resource=$TERRA_CI_RESOURCE\nartifacts:\n files:\n - ./tfplan\n name: $TERRA_CI_BUILD_NAME\n\n", + "git_clone_depth": 1, + "git_submodules_config": [], + "insecure_ssl": false, + "location": "https://github.com/p0tr3c-terraform/terra-ci-single-account.git", + "report_build_status": false, + "type": "GITHUB" + } + ], + "source_version": "", + "tags": {}, + "vpc_config": [] + }, + "after_unknown": {}, + "before_sensitive": { + "artifacts": [ + {} + ], + "cache": [ + { + "modes": [] + } + ], + "environment": [ + { + "environment_variable": [], + "registry_credential": [] + } + ], + "logs_config": [ + { + "cloudwatch_logs": [ + {} + ], + "s3_logs": [ + {} + ] + } + ], + "secondary_artifacts": [], + "secondary_sources": [], + "source": [ + { + "auth": [], + "git_submodules_config": [] + } + ], + "tags": {}, + "vpc_config": [] + }, + "after_sensitive": { + "artifacts": [ + {} + ], + "cache": [ + { + "modes": [] + } + ], + "environment": [ + { + "environment_variable": [], + "registry_credential": [] + } + ], + "logs_config": [ + { + "cloudwatch_logs": [ + {} + ], + "s3_logs": [ + {} + ] + } + ], + "secondary_artifacts": [], + "secondary_sources": [], + "source": [ + { + "auth": [], + "git_submodules_config": [] + } + ], + "tags": {}, + "vpc_config": [] + } + } + }, + { + "address": "aws_iam_role.terra_ci_job", + "mode": "managed", + "type": "aws_iam_role", + "name": "terra_ci_job", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": { + "arn": "arn:aws:iam::719261439472:role/terra_ci_job", + "assume_role_policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"codebuild.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}", + "create_date": "2021-05-01T15:08:15Z", + "description": "", + "force_detach_policies": false, + "id": "terra_ci_job", + "inline_policy": [ + { + "name": "terraform-20210501150816628700000001", + "policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": \"sts:AssumeRole\",\n \"Resource\": \"arn:aws:iam::719261439472:role/ci\"\n },\n {\n \"Effect\": \"Allow\",\n \"Resource\": [\n \"*\"\n ],\n \"Action\": [\n \"logs:CreateLogGroup\",\n \"logs:CreateLogStream\",\n \"logs:PutLogEvents\"\n ]\n },\n {\n \"Effect\": \"Allow\",\n \"Resource\": [\n \"arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002\",\n \"arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002/*\"\n ],\n \"Action\": [\n \"s3:ListBucket\",\n \"s3:*Object\"\n ]\n }\n ]\n}\n" + } + ], + "managed_policy_arns": [ + "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser" + ], + "max_session_duration": 3600, + "name": "terra_ci_job", + "name_prefix": null, + "path": "/", + "permissions_boundary": null, + "tags": {}, + "unique_id": "AROA2O52SSXYL7LBSM733" + }, + "after": { + "arn": "arn:aws:iam::719261439472:role/terra_ci_job", + "assume_role_policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"codebuild.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}", + "create_date": "2021-05-01T15:08:15Z", + "description": "", + "force_detach_policies": false, + "id": "terra_ci_job", + "inline_policy": [ + { + "name": "terraform-20210501150816628700000001", + "policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": \"sts:AssumeRole\",\n \"Resource\": \"arn:aws:iam::719261439472:role/ci\"\n },\n {\n \"Effect\": \"Allow\",\n \"Resource\": [\n \"*\"\n ],\n \"Action\": [\n \"logs:CreateLogGroup\",\n \"logs:CreateLogStream\",\n \"logs:PutLogEvents\"\n ]\n },\n {\n \"Effect\": \"Allow\",\n \"Resource\": [\n \"arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002\",\n \"arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002/*\"\n ],\n \"Action\": [\n \"s3:ListBucket\",\n \"s3:*Object\"\n ]\n }\n ]\n}\n" + } + ], + "managed_policy_arns": [ + "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser" + ], + "max_session_duration": 3600, + "name": "terra_ci_job", + "name_prefix": null, + "path": "/", + "permissions_boundary": null, + "tags": {}, + "unique_id": "AROA2O52SSXYL7LBSM733" + }, + "after_unknown": {}, + "before_sensitive": { + "inline_policy": [ + {} + ], + "managed_policy_arns": [ + false + ], + "tags": {} + }, + "after_sensitive": { + "inline_policy": [ + {} + ], + "managed_policy_arns": [ + false + ], + "tags": {} + } + } + }, + { + "address": "aws_iam_role.terra_ci_runner", + "mode": "managed", + "type": "aws_iam_role", + "name": "terra_ci_runner", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": { + "arn": "arn:aws:iam::719261439472:role/terra_ci_runner", + "assume_role_policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"states.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}", + "create_date": "2021-05-01T15:08:15Z", + "description": "", + "force_detach_policies": false, + "id": "terra_ci_runner", + "inline_policy": [ + { + "name": "terraform-20210501150825425000000003", + "policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"codebuild:StartBuild\",\n \"codebuild:StopBuild\",\n \"codebuild:BatchGetBuilds\"\n ],\n \"Resource\": [\n \"arn:aws:codebuild:eu-west-1:719261439472:project/terra-ci-runner\"\n ]\n },\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"events:PutTargets\",\n \"events:PutRule\",\n \"events:DescribeRule\"\n ],\n \"Resource\": [\n \"arn:aws:events:eu-west-1:719261439472:rule/StepFunctionsGetEventForCodeBuildStartBuildRule\"\n ]\n }\n ]\n}\n" + } + ], + "managed_policy_arns": [], + "max_session_duration": 3600, + "name": "terra_ci_runner", + "name_prefix": null, + "path": "/", + "permissions_boundary": null, + "tags": {}, + "unique_id": "AROA2O52SSXYDBYYTG4OB" + }, + "after": { + "arn": "arn:aws:iam::719261439472:role/terra_ci_runner", + "assume_role_policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"states.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}", + "create_date": "2021-05-01T15:08:15Z", + "description": "", + "force_detach_policies": false, + "id": "terra_ci_runner", + "inline_policy": [ + { + "name": "terraform-20210501150825425000000003", + "policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"codebuild:StartBuild\",\n \"codebuild:StopBuild\",\n \"codebuild:BatchGetBuilds\"\n ],\n \"Resource\": [\n \"arn:aws:codebuild:eu-west-1:719261439472:project/terra-ci-runner\"\n ]\n },\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"events:PutTargets\",\n \"events:PutRule\",\n \"events:DescribeRule\"\n ],\n \"Resource\": [\n \"arn:aws:events:eu-west-1:719261439472:rule/StepFunctionsGetEventForCodeBuildStartBuildRule\"\n ]\n }\n ]\n}\n" + } + ], + "managed_policy_arns": [], + "max_session_duration": 3600, + "name": "terra_ci_runner", + "name_prefix": null, + "path": "/", + "permissions_boundary": null, + "tags": {}, + "unique_id": "AROA2O52SSXYDBYYTG4OB" + }, + "after_unknown": {}, + "before_sensitive": { + "inline_policy": [ + {} + ], + "managed_policy_arns": [], + "tags": {} + }, + "after_sensitive": { + "inline_policy": [ + {} + ], + "managed_policy_arns": [], + "tags": {} + } + } + }, + { + "address": "aws_iam_role_policy.terra_ci_job", + "mode": "managed", + "type": "aws_iam_role_policy", + "name": "terra_ci_job", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": { + "id": "terra_ci_job:terraform-20210501150816628700000001", + "name": "terraform-20210501150816628700000001", + "name_prefix": null, + "policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": \"sts:AssumeRole\",\n \"Resource\": \"arn:aws:iam::719261439472:role/ci\"\n },\n {\n \"Effect\": \"Allow\",\n \"Resource\": [\n \"*\"\n ],\n \"Action\": [\n \"logs:CreateLogGroup\",\n \"logs:CreateLogStream\",\n \"logs:PutLogEvents\"\n ]\n },\n {\n \"Effect\": \"Allow\",\n \"Resource\": [\n \"arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002\",\n \"arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002/*\"\n ],\n \"Action\": [\n \"s3:ListBucket\",\n \"s3:*Object\"\n ]\n }\n ]\n}\n", + "role": "terra_ci_job" + }, + "after": { + "id": "terra_ci_job:terraform-20210501150816628700000001", + "name": "terraform-20210501150816628700000001", + "name_prefix": null, + "policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": \"sts:AssumeRole\",\n \"Resource\": \"arn:aws:iam::719261439472:role/ci\"\n },\n {\n \"Effect\": \"Allow\",\n \"Resource\": [\n \"*\"\n ],\n \"Action\": [\n \"logs:CreateLogGroup\",\n \"logs:CreateLogStream\",\n \"logs:PutLogEvents\"\n ]\n },\n {\n \"Effect\": \"Allow\",\n \"Resource\": [\n \"arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002\",\n \"arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002/*\"\n ],\n \"Action\": [\n \"s3:ListBucket\",\n \"s3:*Object\"\n ]\n }\n ]\n}\n", + "role": "terra_ci_job" + }, + "after_unknown": {}, + "before_sensitive": {}, + "after_sensitive": {} + } + }, + { + "address": "aws_iam_role_policy.terra_ci_runner", + "mode": "managed", + "type": "aws_iam_role_policy", + "name": "terra_ci_runner", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": { + "id": "terra_ci_runner:terraform-20210501150825425000000003", + "name": "terraform-20210501150825425000000003", + "name_prefix": null, + "policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"codebuild:StartBuild\",\n \"codebuild:StopBuild\",\n \"codebuild:BatchGetBuilds\"\n ],\n \"Resource\": [\n \"arn:aws:codebuild:eu-west-1:719261439472:project/terra-ci-runner\"\n ]\n },\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"events:PutTargets\",\n \"events:PutRule\",\n \"events:DescribeRule\"\n ],\n \"Resource\": [\n \"arn:aws:events:eu-west-1:719261439472:rule/StepFunctionsGetEventForCodeBuildStartBuildRule\"\n ]\n }\n ]\n}\n", + "role": "terra_ci_runner" + }, + "after": { + "id": "terra_ci_runner:terraform-20210501150825425000000003", + "name": "terraform-20210501150825425000000003", + "name_prefix": null, + "policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"codebuild:StartBuild\",\n \"codebuild:StopBuild\",\n \"codebuild:BatchGetBuilds\"\n ],\n \"Resource\": [\n \"arn:aws:codebuild:eu-west-1:719261439472:project/terra-ci-runner\"\n ]\n },\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"events:PutTargets\",\n \"events:PutRule\",\n \"events:DescribeRule\"\n ],\n \"Resource\": [\n \"arn:aws:events:eu-west-1:719261439472:rule/StepFunctionsGetEventForCodeBuildStartBuildRule\"\n ]\n }\n ]\n}\n", + "role": "terra_ci_runner" + }, + "after_unknown": {}, + "before_sensitive": {}, + "after_sensitive": {} + } + }, + { + "address": "aws_iam_role_policy_attachment.terra_ci_job_ecr_access", + "mode": "managed", + "type": "aws_iam_role_policy_attachment", + "name": "terra_ci_job_ecr_access", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": { + "id": "terra_ci_job-20210501150817089800000002", + "policy_arn": "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser", + "role": "terra_ci_job" + }, + "after": { + "id": "terra_ci_job-20210501150817089800000002", + "policy_arn": "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser", + "role": "terra_ci_job" + }, + "after_unknown": {}, + "before_sensitive": {}, + "after_sensitive": {} + } + }, + { + "address": "aws_s3_bucket.terra_ci", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "terra_ci", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": { + "acceleration_status": "", + "acl": "private", + "arn": "arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002", + "bucket": "terra-ci-artifacts-eu-west-1-000002", + "bucket_domain_name": "terra-ci-artifacts-eu-west-1-000002.s3.amazonaws.com", + "bucket_prefix": null, + "bucket_regional_domain_name": "terra-ci-artifacts-eu-west-1-000002.s3.eu-west-1.amazonaws.com", + "cors_rule": [], + "force_destroy": false, + "grant": [], + "hosted_zone_id": "Z1BKCTXD74EZPE", + "id": "terra-ci-artifacts-eu-west-1-000002", + "lifecycle_rule": [], + "logging": [], + "object_lock_configuration": [], + "policy": null, + "region": "eu-west-1", + "replication_configuration": [], + "request_payer": "BucketOwner", + "server_side_encryption_configuration": [ + { + "rule": [ + { + "apply_server_side_encryption_by_default": [ + { + "kms_master_key_id": "", + "sse_algorithm": "aws:kms" + } + ], + "bucket_key_enabled": false + } + ] + } + ], + "tags": {}, + "versioning": [ + { + "enabled": false, + "mfa_delete": false + } + ], + "website": [], + "website_domain": null, + "website_endpoint": null + }, + "after": { + "acceleration_status": "", + "acl": "private", + "arn": "arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002", + "bucket": "terra-ci-artifacts-eu-west-1-000002", + "bucket_domain_name": "terra-ci-artifacts-eu-west-1-000002.s3.amazonaws.com", + "bucket_prefix": null, + "bucket_regional_domain_name": "terra-ci-artifacts-eu-west-1-000002.s3.eu-west-1.amazonaws.com", + "cors_rule": [], + "force_destroy": false, + "grant": [], + "hosted_zone_id": "Z1BKCTXD74EZPE", + "id": "terra-ci-artifacts-eu-west-1-000002", + "lifecycle_rule": [], + "logging": [], + "object_lock_configuration": [], + "policy": null, + "region": "eu-west-1", + "replication_configuration": [], + "request_payer": "BucketOwner", + "server_side_encryption_configuration": [ + { + "rule": [ + { + "apply_server_side_encryption_by_default": [ + { + "kms_master_key_id": "", + "sse_algorithm": "aws:kms" + } + ], + "bucket_key_enabled": false + } + ] + } + ], + "tags": {}, + "versioning": [ + { + "enabled": false, + "mfa_delete": false + } + ], + "website": [], + "website_domain": null, + "website_endpoint": null + }, + "after_unknown": {}, + "before_sensitive": { + "cors_rule": [], + "grant": [], + "lifecycle_rule": [], + "logging": [], + "object_lock_configuration": [], + "replication_configuration": [], + "server_side_encryption_configuration": [ + { + "rule": [ + { + "apply_server_side_encryption_by_default": [ + {} + ] + } + ] + } + ], + "tags": {}, + "versioning": [ + {} + ], + "website": [] + }, + "after_sensitive": { + "cors_rule": [], + "grant": [], + "lifecycle_rule": [], + "logging": [], + "object_lock_configuration": [], + "replication_configuration": [], + "server_side_encryption_configuration": [ + { + "rule": [ + { + "apply_server_side_encryption_by_default": [ + {} + ] + } + ] + } + ], + "tags": {}, + "versioning": [ + {} + ], + "website": [] + } + } + }, + { + "address": "aws_sfn_state_machine.terra_ci_runner", + "mode": "managed", + "type": "aws_sfn_state_machine", + "name": "terra_ci_runner", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": { + "arn": "arn:aws:states:eu-west-1:719261439472:stateMachine:terra-ci-runner", + "creation_date": "2021-05-01T15:09:28Z", + "definition": "{\n \"Comment\": \"Run Terragrunt Jobs\",\n \"StartAt\": \"OnBranch?\",\n \"States\": {\n \"OnBranch?\": {\n \"Type\": \"Choice\",\n \"Choices\": [\n {\n \"Variable\": \"$.build.sourceversion\",\n \"IsPresent\": true,\n \"Next\": \"PlanBranch\"\n }\n ],\n \"Default\": \"Plan\"\n },\n \"Plan\": {\n \"Type\": \"Task\",\n \"Resource\": \"arn:aws:states:::codebuild:startBuild.sync\",\n \"Parameters\": {\n \"ProjectName\": \"terra-ci-runner\",\n \"EnvironmentVariablesOverride\": [\n {\n \"Name\": \"TERRA_CI_BUILD_NAME\",\n \"Value.$\": \"$$.Execution.Name\"\n },\n {\n \"Name\": \"TERRA_CI_RESOURCE\",\n \"Value.$\": \"$.build.environment.terra_ci_resource\"\n }\n ]\n },\n \"End\": true\n },\n \"PlanBranch\": {\n \"Type\": \"Task\",\n \"Resource\": \"arn:aws:states:::codebuild:startBuild.sync\",\n \"Parameters\": {\n \"ProjectName\": \"terra-ci-runner\",\n \"SourceVersion.$\": \"$.build.sourceversion\",\n \"EnvironmentVariablesOverride\": [\n {\n \"Name\": \"TERRA_CI_RESOURCE\",\n \"Value.$\": \"$.build.environment.terra_ci_resource\"\n }\n ]\n },\n \"End\": true\n }\n }\n}\n", + "id": "arn:aws:states:eu-west-1:719261439472:stateMachine:terra-ci-runner", + "logging_configuration": [ + { + "include_execution_data": false, + "level": "OFF", + "log_destination": "" + } + ], + "name": "terra-ci-runner", + "role_arn": "arn:aws:iam::719261439472:role/terra_ci_runner", + "status": "ACTIVE", + "tags": {}, + "type": "STANDARD" + }, + "after": { + "arn": "arn:aws:states:eu-west-1:719261439472:stateMachine:terra-ci-runner", + "creation_date": "2021-05-01T15:09:28Z", + "definition": "{\n \"Comment\": \"Run Terragrunt Jobs\",\n \"StartAt\": \"OnBranch?\",\n \"States\": {\n \"OnBranch?\": {\n \"Type\": \"Choice\",\n \"Choices\": [\n {\n \"Variable\": \"$.build.sourceversion\",\n \"IsPresent\": true,\n \"Next\": \"PlanBranch\"\n }\n ],\n \"Default\": \"Plan\"\n },\n \"Plan\": {\n \"Type\": \"Task\",\n \"Resource\": \"arn:aws:states:::codebuild:startBuild.sync\",\n \"Parameters\": {\n \"ProjectName\": \"terra-ci-runner\",\n \"EnvironmentVariablesOverride\": [\n {\n \"Name\": \"TERRA_CI_BUILD_NAME\",\n \"Value.$\": \"$$.Execution.Name\"\n },\n {\n \"Name\": \"TERRA_CI_RESOURCE\",\n \"Value.$\": \"$.build.environment.terra_ci_resource\"\n }\n ]\n },\n \"End\": true\n },\n \"PlanBranch\": {\n \"Type\": \"Task\",\n \"Resource\": \"arn:aws:states:::codebuild:startBuild.sync\",\n \"Parameters\": {\n \"ProjectName\": \"terra-ci-runner\",\n \"SourceVersion.$\": \"$.build.sourceversion\",\n \"EnvironmentVariablesOverride\": [\n {\n \"Name\": \"TERRA_CI_RESOURCE\",\n \"Value.$\": \"$.build.environment.terra_ci_resource\"\n }\n ]\n },\n \"End\": true\n }\n }\n}\n", + "id": "arn:aws:states:eu-west-1:719261439472:stateMachine:terra-ci-runner", + "logging_configuration": [ + { + "include_execution_data": false, + "level": "OFF", + "log_destination": "" + } + ], + "name": "terra-ci-runner", + "role_arn": "arn:aws:iam::719261439472:role/terra_ci_runner", + "status": "ACTIVE", + "tags": {}, + "type": "STANDARD" + }, + "after_unknown": {}, + "before_sensitive": { + "logging_configuration": [ + {} + ], + "tags": {} + }, + "after_sensitive": { + "logging_configuration": [ + {} + ], + "tags": {} + } + } + } + ], + "prior_state": { + "format_version": "0.1", + "terraform_version": "0.15.0", + "values": { + "root_module": { + "resources": [ + { + "address": "aws_codebuild_project.terra_ci", + "mode": "managed", + "type": "aws_codebuild_project", + "name": "terra_ci", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "arn": "arn:aws:codebuild:eu-west-1:719261439472:project/terra-ci-runner", + "artifacts": [ + { + "artifact_identifier": "", + "encryption_disabled": false, + "location": "terra-ci-artifacts-eu-west-1-000002", + "name": "terra-ci-runner", + "namespace_type": "NONE", + "override_artifact_name": false, + "packaging": "NONE", + "path": "", + "type": "S3" + } + ], + "badge_enabled": false, + "badge_url": "", + "build_timeout": 10, + "cache": [ + { + "location": "", + "modes": [], + "type": "NO_CACHE" + } + ], + "description": "Deploy environment configuration", + "encryption_key": "arn:aws:kms:eu-west-1:719261439472:alias/aws/s3", + "environment": [ + { + "certificate": "", + "compute_type": "BUILD_GENERAL1_SMALL", + "environment_variable": [], + "image": "aws/codebuild/amazonlinux2-x86_64-standard:2.0", + "image_pull_credentials_type": "CODEBUILD", + "privileged_mode": false, + "registry_credential": [], + "type": "LINUX_CONTAINER" + } + ], + "id": "arn:aws:codebuild:eu-west-1:719261439472:project/terra-ci-runner", + "logs_config": [ + { + "cloudwatch_logs": [ + { + "group_name": "", + "status": "ENABLED", + "stream_name": "" + } + ], + "s3_logs": [ + { + "encryption_disabled": false, + "location": "", + "status": "DISABLED" + } + ] + } + ], + "name": "terra-ci-runner", + "queued_timeout": 480, + "secondary_artifacts": [], + "secondary_sources": [], + "service_role": "arn:aws:iam::719261439472:role/terra_ci_job", + "source": [ + { + "auth": [], + "buildspec": "version: 0.2\nphases:\n install:\n commands:\n - make install_tools\n build:\n commands:\n - make plan_local resource=$TERRA_CI_RESOURCE\nartifacts:\n files:\n - ./tfplan\n name: $TERRA_CI_BUILD_NAME\n\n", + "git_clone_depth": 1, + "git_submodules_config": [], + "insecure_ssl": false, + "location": "https://github.com/p0tr3c-terraform/terra-ci-single-account.git", + "report_build_status": false, + "type": "GITHUB" + } + ], + "source_version": "", + "tags": {}, + "vpc_config": [] + }, + "depends_on": [ + "aws_iam_role.terra_ci_job", + "aws_s3_bucket.terra_ci", + "data.template_file.terra_ci" + ] + }, + { + "address": "aws_iam_role.terra_ci_job", + "mode": "managed", + "type": "aws_iam_role", + "name": "terra_ci_job", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "arn": "arn:aws:iam::719261439472:role/terra_ci_job", + "assume_role_policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"codebuild.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}", + "create_date": "2021-05-01T15:08:15Z", + "description": "", + "force_detach_policies": false, + "id": "terra_ci_job", + "inline_policy": [ + { + "name": "terraform-20210501150816628700000001", + "policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": \"sts:AssumeRole\",\n \"Resource\": \"arn:aws:iam::719261439472:role/ci\"\n },\n {\n \"Effect\": \"Allow\",\n \"Resource\": [\n \"*\"\n ],\n \"Action\": [\n \"logs:CreateLogGroup\",\n \"logs:CreateLogStream\",\n \"logs:PutLogEvents\"\n ]\n },\n {\n \"Effect\": \"Allow\",\n \"Resource\": [\n \"arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002\",\n \"arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002/*\"\n ],\n \"Action\": [\n \"s3:ListBucket\",\n \"s3:*Object\"\n ]\n }\n ]\n}\n" + } + ], + "managed_policy_arns": [ + "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser" + ], + "max_session_duration": 3600, + "name": "terra_ci_job", + "name_prefix": null, + "path": "/", + "permissions_boundary": null, + "tags": {}, + "unique_id": "AROA2O52SSXYL7LBSM733" + } + }, + { + "address": "aws_iam_role.terra_ci_runner", + "mode": "managed", + "type": "aws_iam_role", + "name": "terra_ci_runner", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "arn": "arn:aws:iam::719261439472:role/terra_ci_runner", + "assume_role_policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"states.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}", + "create_date": "2021-05-01T15:08:15Z", + "description": "", + "force_detach_policies": false, + "id": "terra_ci_runner", + "inline_policy": [ + { + "name": "terraform-20210501150825425000000003", + "policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"codebuild:StartBuild\",\n \"codebuild:StopBuild\",\n \"codebuild:BatchGetBuilds\"\n ],\n \"Resource\": [\n \"arn:aws:codebuild:eu-west-1:719261439472:project/terra-ci-runner\"\n ]\n },\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"events:PutTargets\",\n \"events:PutRule\",\n \"events:DescribeRule\"\n ],\n \"Resource\": [\n \"arn:aws:events:eu-west-1:719261439472:rule/StepFunctionsGetEventForCodeBuildStartBuildRule\"\n ]\n }\n ]\n}\n" + } + ], + "managed_policy_arns": [], + "max_session_duration": 3600, + "name": "terra_ci_runner", + "name_prefix": null, + "path": "/", + "permissions_boundary": null, + "tags": {}, + "unique_id": "AROA2O52SSXYDBYYTG4OB" + } + }, + { + "address": "aws_iam_role_policy.terra_ci_job", + "mode": "managed", + "type": "aws_iam_role_policy", + "name": "terra_ci_job", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "id": "terra_ci_job:terraform-20210501150816628700000001", + "name": "terraform-20210501150816628700000001", + "name_prefix": null, + "policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": \"sts:AssumeRole\",\n \"Resource\": \"arn:aws:iam::719261439472:role/ci\"\n },\n {\n \"Effect\": \"Allow\",\n \"Resource\": [\n \"*\"\n ],\n \"Action\": [\n \"logs:CreateLogGroup\",\n \"logs:CreateLogStream\",\n \"logs:PutLogEvents\"\n ]\n },\n {\n \"Effect\": \"Allow\",\n \"Resource\": [\n \"arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002\",\n \"arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002/*\"\n ],\n \"Action\": [\n \"s3:ListBucket\",\n \"s3:*Object\"\n ]\n }\n ]\n}\n", + "role": "terra_ci_job" + }, + "depends_on": [ + "aws_iam_role.terra_ci_job", + "aws_s3_bucket.terra_ci", + "data.aws_caller_identity.current" + ] + }, + { + "address": "aws_iam_role_policy.terra_ci_runner", + "mode": "managed", + "type": "aws_iam_role_policy", + "name": "terra_ci_runner", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "id": "terra_ci_runner:terraform-20210501150825425000000003", + "name": "terraform-20210501150825425000000003", + "name_prefix": null, + "policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"codebuild:StartBuild\",\n \"codebuild:StopBuild\",\n \"codebuild:BatchGetBuilds\"\n ],\n \"Resource\": [\n \"arn:aws:codebuild:eu-west-1:719261439472:project/terra-ci-runner\"\n ]\n },\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"events:PutTargets\",\n \"events:PutRule\",\n \"events:DescribeRule\"\n ],\n \"Resource\": [\n \"arn:aws:events:eu-west-1:719261439472:rule/StepFunctionsGetEventForCodeBuildStartBuildRule\"\n ]\n }\n ]\n}\n", + "role": "terra_ci_runner" + }, + "depends_on": [ + "data.template_file.terra_ci", + "aws_codebuild_project.terra_ci", + "aws_iam_role.terra_ci_job", + "aws_iam_role.terra_ci_runner", + "aws_s3_bucket.terra_ci", + "data.aws_caller_identity.current" + ] + }, + { + "address": "aws_iam_role_policy_attachment.terra_ci_job_ecr_access", + "mode": "managed", + "type": "aws_iam_role_policy_attachment", + "name": "terra_ci_job_ecr_access", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "id": "terra_ci_job-20210501150817089800000002", + "policy_arn": "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser", + "role": "terra_ci_job" + }, + "depends_on": [ + "aws_iam_role.terra_ci_job" + ] + }, + { + "address": "aws_s3_bucket.terra_ci", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "terra_ci", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "acceleration_status": "", + "acl": "private", + "arn": "arn:aws:s3:::terra-ci-artifacts-eu-west-1-000002", + "bucket": "terra-ci-artifacts-eu-west-1-000002", + "bucket_domain_name": "terra-ci-artifacts-eu-west-1-000002.s3.amazonaws.com", + "bucket_prefix": null, + "bucket_regional_domain_name": "terra-ci-artifacts-eu-west-1-000002.s3.eu-west-1.amazonaws.com", + "cors_rule": [], + "force_destroy": false, + "grant": [], + "hosted_zone_id": "Z1BKCTXD74EZPE", + "id": "terra-ci-artifacts-eu-west-1-000002", + "lifecycle_rule": [], + "logging": [], + "object_lock_configuration": [], + "policy": null, + "region": "eu-west-1", + "replication_configuration": [], + "request_payer": "BucketOwner", + "server_side_encryption_configuration": [ + { + "rule": [ + { + "apply_server_side_encryption_by_default": [ + { + "kms_master_key_id": "", + "sse_algorithm": "aws:kms" + } + ], + "bucket_key_enabled": false + } + ] + } + ], + "tags": {}, + "versioning": [ + { + "enabled": false, + "mfa_delete": false + } + ], + "website": [], + "website_domain": null, + "website_endpoint": null + } + }, + { + "address": "aws_sfn_state_machine.terra_ci_runner", + "mode": "managed", + "type": "aws_sfn_state_machine", + "name": "terra_ci_runner", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "arn": "arn:aws:states:eu-west-1:719261439472:stateMachine:terra-ci-runner", + "creation_date": "2021-05-01T15:09:28Z", + "definition": "{\n \"Comment\": \"Run Terragrunt Jobs\",\n \"StartAt\": \"OnBranch?\",\n \"States\": {\n \"OnBranch?\": {\n \"Type\": \"Choice\",\n \"Choices\": [\n {\n \"Variable\": \"$.build.sourceversion\",\n \"IsPresent\": true,\n \"Next\": \"PlanBranch\"\n }\n ],\n \"Default\": \"Plan\"\n },\n \"Plan\": {\n \"Type\": \"Task\",\n \"Resource\": \"arn:aws:states:::codebuild:startBuild.sync\",\n \"Parameters\": {\n \"ProjectName\": \"terra-ci-runner\",\n \"EnvironmentVariablesOverride\": [\n {\n \"Name\": \"TERRA_CI_BUILD_NAME\",\n \"Value.$\": \"$$.Execution.Name\"\n },\n {\n \"Name\": \"TERRA_CI_RESOURCE\",\n \"Value.$\": \"$.build.environment.terra_ci_resource\"\n }\n ]\n },\n \"End\": true\n },\n \"PlanBranch\": {\n \"Type\": \"Task\",\n \"Resource\": \"arn:aws:states:::codebuild:startBuild.sync\",\n \"Parameters\": {\n \"ProjectName\": \"terra-ci-runner\",\n \"SourceVersion.$\": \"$.build.sourceversion\",\n \"EnvironmentVariablesOverride\": [\n {\n \"Name\": \"TERRA_CI_RESOURCE\",\n \"Value.$\": \"$.build.environment.terra_ci_resource\"\n }\n ]\n },\n \"End\": true\n }\n }\n}\n", + "id": "arn:aws:states:eu-west-1:719261439472:stateMachine:terra-ci-runner", + "logging_configuration": [ + { + "include_execution_data": false, + "level": "OFF", + "log_destination": "" + } + ], + "name": "terra-ci-runner", + "role_arn": "arn:aws:iam::719261439472:role/terra_ci_runner", + "status": "ACTIVE", + "tags": {}, + "type": "STANDARD" + }, + "depends_on": [ + "aws_codebuild_project.terra_ci", + "aws_iam_role.terra_ci_job", + "aws_iam_role.terra_ci_runner", + "aws_s3_bucket.terra_ci", + "data.template_file.terra_ci" + ] + }, + { + "address": "data.aws_caller_identity.current", + "mode": "data", + "type": "aws_caller_identity", + "name": "current", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "account_id": "719261439472", + "arn": "arn:aws:sts::719261439472:assumed-role/ci/1620199336456840848", + "id": "719261439472", + "user_id": "AROA2O52SSXYLVFYURBIV:1620199336456840848" + } + }, + { + "address": "data.template_file.terra_ci", + "mode": "data", + "type": "template_file", + "name": "terra_ci", + "provider_name": "registry.terraform.io/hashicorp/template", + "schema_version": 0, + "values": { + "filename": null, + "id": "64e36ed71e7270140dde96fec9c89d1d55ae5a6e91f7c0be15170200dcf9481b", + "rendered": "version: 0.2\nphases:\n install:\n commands:\n - make install_tools\n build:\n commands:\n - make plan_local resource=$TERRA_CI_RESOURCE\nartifacts:\n files:\n - ./tfplan\n name: $TERRA_CI_BUILD_NAME\n\n", + "template": "version: 0.2\nphases:\n install:\n commands:\n - make install_tools\n build:\n commands:\n - make plan_local resource=$TERRA_CI_RESOURCE\nartifacts:\n files:\n - ./tfplan\n name: $TERRA_CI_BUILD_NAME\n\n", + "vars": null + } + } + ] + } + } + }, + "configuration": { + "provider_config": { + "aws": { + "name": "aws", + "expressions": { + "allowed_account_ids": { + "constant_value": [ + "719261439472" + ] + }, + "assume_role": [ + { + "role_arn": { + "constant_value": "arn:aws:iam::719261439472:role/ci" + } + } + ], + "region": { + "constant_value": "eu-west-1" + } + } + } + }, + "root_module": { + "resources": [ + { + "address": "aws_codebuild_project.terra_ci", + "mode": "managed", + "type": "aws_codebuild_project", + "name": "terra_ci", + "provider_config_key": "aws", + "expressions": { + "artifacts": [ + { + "location": { + "references": [ + "aws_s3_bucket.terra_ci" + ] + }, + "type": { + "constant_value": "S3" + } + } + ], + "build_timeout": { + "constant_value": "10" + }, + "description": { + "constant_value": "Deploy environment configuration" + }, + "environment": [ + { + "compute_type": { + "constant_value": "BUILD_GENERAL1_SMALL" + }, + "image": { + "constant_value": "aws/codebuild/amazonlinux2-x86_64-standard:2.0" + }, + "image_pull_credentials_type": { + "constant_value": "CODEBUILD" + }, + "privileged_mode": { + "constant_value": false + }, + "type": { + "constant_value": "LINUX_CONTAINER" + } + } + ], + "logs_config": [ + { + "cloudwatch_logs": [ + { + "status": { + "constant_value": "ENABLED" + } + } + ], + "s3_logs": [ + { + "encryption_disabled": { + "constant_value": false + }, + "status": { + "constant_value": "DISABLED" + } + } + ] + } + ], + "name": { + "constant_value": "terra-ci-runner" + }, + "service_role": { + "references": [ + "aws_iam_role.terra_ci_job" + ] + }, + "source": [ + { + "buildspec": { + "references": [ + "data.template_file.terra_ci" + ] + }, + "git_clone_depth": { + "constant_value": 1 + }, + "insecure_ssl": { + "constant_value": false + }, + "location": { + "references": [ + "var.repo_url" + ] + }, + "report_build_status": { + "constant_value": false + }, + "type": { + "constant_value": "GITHUB" + } + } + ] + }, + "schema_version": 0 + }, + { + "address": "aws_iam_role.terra_ci_job", + "mode": "managed", + "type": "aws_iam_role", + "name": "terra_ci_job", + "provider_config_key": "aws", + "expressions": { + "assume_role_policy": { + "constant_value": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Principal\": {\n \"Service\": \"codebuild.amazonaws.com\"\n },\n \"Action\": \"sts:AssumeRole\"\n }\n ]\n}\n" + }, + "name": { + "constant_value": "terra_ci_job" + } + }, + "schema_version": 0 + }, + { + "address": "aws_iam_role.terra_ci_runner", + "mode": "managed", + "type": "aws_iam_role", + "name": "terra_ci_runner", + "provider_config_key": "aws", + "expressions": { + "assume_role_policy": { + "constant_value": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Principal\": {\n \"Service\": \"states.amazonaws.com\"\n },\n \"Action\": \"sts:AssumeRole\"\n }\n ]\n}\n" + }, + "name": { + "constant_value": "terra_ci_runner" + } + }, + "schema_version": 0 + }, + { + "address": "aws_iam_role_policy.terra_ci_job", + "mode": "managed", + "type": "aws_iam_role_policy", + "name": "terra_ci_job", + "provider_config_key": "aws", + "expressions": { + "policy": { + "references": [ + "data.aws_caller_identity.current", + "aws_s3_bucket.terra_ci", + "aws_s3_bucket.terra_ci" + ] + }, + "role": { + "references": [ + "aws_iam_role.terra_ci_job" + ] + } + }, + "schema_version": 0 + }, + { + "address": "aws_iam_role_policy.terra_ci_runner", + "mode": "managed", + "type": "aws_iam_role_policy", + "name": "terra_ci_runner", + "provider_config_key": "aws", + "expressions": { + "policy": { + "references": [ + "aws_codebuild_project.terra_ci", + "var.aws_region", + "data.aws_caller_identity.current" + ] + }, + "role": { + "references": [ + "aws_iam_role.terra_ci_runner" + ] + } + }, + "schema_version": 0 + }, + { + "address": "aws_iam_role_policy_attachment.terra_ci_job_ecr_access", + "mode": "managed", + "type": "aws_iam_role_policy_attachment", + "name": "terra_ci_job_ecr_access", + "provider_config_key": "aws", + "expressions": { + "policy_arn": { + "constant_value": "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser" + }, + "role": { + "references": [ + "aws_iam_role.terra_ci_job" + ] + } + }, + "schema_version": 0 + }, + { + "address": "aws_s3_bucket.terra_ci", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "terra_ci", + "provider_config_key": "aws", + "expressions": { + "acl": { + "constant_value": "private" + }, + "bucket": { + "references": [ + "var.aws_region", + "var.serial_number" + ] + }, + "server_side_encryption_configuration": [ + { + "rule": [ + { + "apply_server_side_encryption_by_default": [ + { + "sse_algorithm": { + "constant_value": "aws:kms" + } + } + ], + "bucket_key_enabled": { + "constant_value": false + } + } + ] + } + ] + }, + "schema_version": 0 + }, + { + "address": "aws_sfn_state_machine.terra_ci_runner", + "mode": "managed", + "type": "aws_sfn_state_machine", + "name": "terra_ci_runner", + "provider_config_key": "aws", + "expressions": { + "definition": { + "references": [ + "aws_codebuild_project.terra_ci", + "aws_codebuild_project.terra_ci" + ] + }, + "name": { + "constant_value": "terra-ci-runner" + }, + "role_arn": { + "references": [ + "aws_iam_role.terra_ci_runner" + ] + } + }, + "schema_version": 0 + }, + { + "address": "data.aws_caller_identity.current", + "mode": "data", + "type": "aws_caller_identity", + "name": "current", + "provider_config_key": "aws", + "schema_version": 0 + }, + { + "address": "data.template_file.terra_ci", + "mode": "data", + "type": "template_file", + "name": "terra_ci", + "provider_config_key": "template", + "expressions": { + "template": {} + }, + "schema_version": 0 + } + ], + "variables": { + "aws_region": {}, + "repo_url": {}, + "serial_number": {} + } + } + } + } diff --git a/tests/test_files/tf_content_generator_files/tfplan-null-example/tf_content.txt b/tests/test_files/tf_content_generator_files/tfplan-null-example/tf_content.txt new file mode 100644 index 00000000..a0ac1510 --- /dev/null +++ b/tests/test_files/tf_content_generator_files/tfplan-null-example/tf_content.txt @@ -0,0 +1,4 @@ +resource "null_resource" "empty" { + triggers = null +} + diff --git a/tests/test_files/tf_content_generator_files/tfplan-null-example/tfplan.json b/tests/test_files/tf_content_generator_files/tfplan-null-example/tfplan.json new file mode 100644 index 00000000..fe05b388 --- /dev/null +++ b/tests/test_files/tf_content_generator_files/tfplan-null-example/tfplan.json @@ -0,0 +1,75 @@ +{ + "format_version": "1.0", + "terraform_version": "1.1.9", + "variables": { + "environment": { + "value": "test" + } + }, + "planned_values": { + "root_module": { + "resources": [ + { + "address": "null_resource.empty", + "mode": "managed", + "type": "null_resource", + "name": "empty", + "provider_name": "registry.terraform.io/hashicorp/null", + "schema_version": 0, + "values": { + "triggers": null + }, + "sensitive_values": {} + } + ] + } + }, + "resource_changes": [ + { + "address": "null_resource.empty", + "mode": "managed", + "type": "null_resource", + "name": "empty", + "provider_name": "registry.terraform.io/hashicorp/null", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "triggers": null + }, + "after_unknown": { + "id": true + }, + "before_sensitive": false, + "after_sensitive": {} + } + } + ], + "configuration": { + "provider_config": { + "aws": { + "name": "aws", + "version_constraint": ">= 4.12.1" + } + }, + "root_module": { + "resources": [ + { + "address": "null_resource.empty", + "mode": "managed", + "type": "null_resource", + "name": "empty", + "provider_config_key": "null", + "schema_version": 0 + } + ], + "variables": { + "environment": { + "description": "The name of the deployment environment" + } + } + } + } +} \ No newline at end of file diff --git a/tests/test_files/tf_content_generator_files/tfplan-update-example/tf_content.txt b/tests/test_files/tf_content_generator_files/tfplan-update-example/tf_content.txt new file mode 100644 index 00000000..0e7a588a --- /dev/null +++ b/tests/test_files/tf_content_generator_files/tfplan-update-example/tf_content.txt @@ -0,0 +1,34 @@ +resource "aws_codebuild_project" "some_projed" { + arn = "arn:aws:codebuild:eu-west-1:719261439472:project/why-my-project-not-working" + artifacts = [{"artifact_identifier": "", "encryption_disabled": true, "location": "terra-ci-artifacts-eu-west-1-000002", "name": "why-my-project-not-working", "namespace_type": "NONE", "override_artifact_name": true, "packaging": "NONE", "path": "", "type": "S3"}] + badge_enabled = false + badge_url = "" + build_timeout = 10 + cache = [{"location": "", "modes": [], "type": "NO_CACHE"}] + description = "Deploy environment configuration" + encryption_key = "arn:aws:kms:eu-west-1:719261439472:alias/aws/s3" + environment = [{"certificate": "", "compute_type": "BUILD_GENERAL1_SMALL", "environment_variable": [], "image": "aws/codebuild/amazonlinux2-x86_64-standard:2.0", "image_pull_credentials_type": "CODEBUILD", "privileged_mode": false, "registry_credential": [], "type": "LINUX_CONTAINER"}] + id = "arn:aws:codebuild:eu-west-1:719261439472:project/why-my-project-not-working" + logs_config = [{"cloudwatch_logs": [{"group_name": "", "status": "ENABLED", "stream_name": ""}], "s3_logs": [{"encryption_disabled": false, "location": "", "status": "DISABLED"}]}] + name = "why-my-project-not-working" + queued_timeout = 480 + secondary_artifacts = [] + secondary_sources = [] + service_role = "arn:aws:iam::719261439472:role/terra_ci_job" + source = [{"auth": [], "buildspec": "version: 0.2\nphases:\n install:\n commands:\n - make install_tools\n build:\n commands:\n - make plan_local resource=$TERRA_CI_RESOURCE\nartifacts:\n files:\n - ./tfplan\n name: $TERRA_CI_BUILD_NAME\n", "git_clone_depth": 1, "git_submodules_config": [], "insecure_ssl": false, "location": "https://github.com/p0tr3c-terraform/terra-ci-single-account.git", "report_build_status": false, "type": "GITHUB"}] + source_version = "" + tags = {} + vpc_config = [] +} + +resource "aws_iam_user" "ci" { + arn = "arn:aws:iam::719261439472:user/ci" + force_destroy = false + id = "ci" + name = "ci" + path = "/" + permissions_boundary = null + tags = {} + unique_id = "AIDA2O52SSXYORYI4EPXD" +} + diff --git a/tests/test_files/tf_content_generator_files/tfplan-update-example/tfplan.json b/tests/test_files/tf_content_generator_files/tfplan-update-example/tfplan.json new file mode 100644 index 00000000..3dde57e2 --- /dev/null +++ b/tests/test_files/tf_content_generator_files/tfplan-update-example/tfplan.json @@ -0,0 +1,736 @@ +{ + "format_version": "0.1", + "terraform_version": "0.15.0", + "variables": { + "AWSServiceRoleForAPIGatewayPresent": { + "value": false + }, + "AWSServiceRoleForAmazonEKSPresent": { + "value": false + }, + "AWSServiceRoleForAutoScalingPresent": { + "value": false + }, + "AWSServiceRoleForOrganizationsPresent": { + "value": false + }, + "AWSServiceRoleForSupportPresent": { + "value": false + }, + "AWSServiceRoleForTrustedAdvisorPresent": { + "value": false + }, + "OrganizationAccountAccessRolePresent": { + "value": false + }, + "aws_account_id": { + "value": "719261439472" + } + }, + "planned_values": { + "root_module": { + "resources": [ + { + "address": "aws_codebuild_project.some_projed", + "mode": "managed", + "type": "aws_codebuild_project", + "name": "some_projed", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "arn": "arn:aws:codebuild:eu-west-1:719261439472:project/why-my-project-not-working", + "artifacts": [ + { + "artifact_identifier": "", + "encryption_disabled": true, + "location": "terra-ci-artifacts-eu-west-1-000002", + "name": "why-my-project-not-working", + "namespace_type": "NONE", + "override_artifact_name": true, + "packaging": "NONE", + "path": "", + "type": "S3" + } + ], + "badge_enabled": false, + "badge_url": "", + "build_timeout": 10, + "cache": [ + { + "location": "", + "modes": [], + "type": "NO_CACHE" + } + ], + "description": "Deploy environment configuration", + "encryption_key": "arn:aws:kms:eu-west-1:719261439472:alias/aws/s3", + "environment": [ + { + "certificate": "", + "compute_type": "BUILD_GENERAL1_SMALL", + "environment_variable": [], + "image": "aws/codebuild/amazonlinux2-x86_64-standard:2.0", + "image_pull_credentials_type": "CODEBUILD", + "privileged_mode": false, + "registry_credential": [], + "type": "LINUX_CONTAINER" + } + ], + "id": "arn:aws:codebuild:eu-west-1:719261439472:project/why-my-project-not-working", + "logs_config": [ + { + "cloudwatch_logs": [ + { + "group_name": "", + "status": "ENABLED", + "stream_name": "" + } + ], + "s3_logs": [ + { + "encryption_disabled": false, + "location": "", + "status": "DISABLED" + } + ] + } + ], + "name": "why-my-project-not-working", + "queued_timeout": 480, + "secondary_artifacts": [], + "secondary_sources": [], + "service_role": "arn:aws:iam::719261439472:role/terra_ci_job", + "source": [ + { + "auth": [], + "buildspec": "version: 0.2\nphases:\n install:\n commands:\n - make install_tools\n build:\n commands:\n - make plan_local resource=$TERRA_CI_RESOURCE\nartifacts:\n files:\n - ./tfplan\n name: $TERRA_CI_BUILD_NAME\n", + "git_clone_depth": 1, + "git_submodules_config": [], + "insecure_ssl": false, + "location": "https://github.com/p0tr3c-terraform/terra-ci-single-account.git", + "report_build_status": false, + "type": "GITHUB" + } + ], + "source_version": "", + "tags": {}, + "vpc_config": [] + } + }, + { + "address": "aws_iam_user.ci", + "mode": "managed", + "type": "aws_iam_user", + "name": "ci", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "arn": "arn:aws:iam::719261439472:user/ci", + "force_destroy": false, + "id": "ci", + "name": "ci", + "path": "/", + "permissions_boundary": null, + "tags": {}, + "unique_id": "AIDA2O52SSXYORYI4EPXD" + } + } + ] + } + }, + "resource_changes": [ + { + "address": "aws_codebuild_project.some_projed", + "mode": "managed", + "type": "aws_codebuild_project", + "name": "some_projed", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "update" + ], + "before": { + "arn": "arn:aws:codebuild:eu-west-1:719261439472:project/why-my-project-not-working", + "artifacts": [ + { + "artifact_identifier": "", + "encryption_disabled": false, + "location": "terra-ci-artifacts-eu-west-1-000002", + "name": "why-my-project-not-working", + "namespace_type": "NONE", + "override_artifact_name": true, + "packaging": "NONE", + "path": "", + "type": "S3" + } + ], + "badge_enabled": false, + "badge_url": "", + "build_timeout": 10, + "cache": [ + { + "location": "", + "modes": [], + "type": "NO_CACHE" + } + ], + "description": "Deploy environment configuration", + "encryption_key": "arn:aws:kms:eu-west-1:719261439472:alias/aws/s3", + "environment": [ + { + "certificate": "", + "compute_type": "BUILD_GENERAL1_SMALL", + "environment_variable": [], + "image": "aws/codebuild/amazonlinux2-x86_64-standard:2.0", + "image_pull_credentials_type": "CODEBUILD", + "privileged_mode": false, + "registry_credential": [], + "type": "LINUX_CONTAINER" + } + ], + "id": "arn:aws:codebuild:eu-west-1:719261439472:project/why-my-project-not-working", + "logs_config": [ + { + "cloudwatch_logs": [ + { + "group_name": "", + "status": "ENABLED", + "stream_name": "" + } + ], + "s3_logs": [ + { + "encryption_disabled": false, + "location": "", + "status": "DISABLED" + } + ] + } + ], + "name": "why-my-project-not-working", + "queued_timeout": 480, + "secondary_artifacts": [], + "secondary_sources": [], + "service_role": "arn:aws:iam::719261439472:role/terra_ci_job", + "source": [ + { + "auth": [], + "buildspec": "version: 0.2\nphases:\n install:\n commands:\n - make install_tools\n build:\n commands:\n - make plan_local resource=$TERRA_CI_RESOURCE\nartifacts:\n files:\n - ./tfplan\n name: $TERRA_CI_BUILD_NAME\n", + "git_clone_depth": 1, + "git_submodules_config": [], + "insecure_ssl": false, + "location": "https://github.com/p0tr3c-terraform/terra-ci-single-account.git", + "report_build_status": false, + "type": "GITHUB" + } + ], + "source_version": "", + "tags": {}, + "vpc_config": [] + }, + "after": { + "arn": "arn:aws:codebuild:eu-west-1:719261439472:project/why-my-project-not-working", + "artifacts": [ + { + "artifact_identifier": "", + "encryption_disabled": true, + "location": "terra-ci-artifacts-eu-west-1-000002", + "name": "why-my-project-not-working", + "namespace_type": "NONE", + "override_artifact_name": true, + "packaging": "NONE", + "path": "", + "type": "S3" + } + ], + "badge_enabled": false, + "badge_url": "", + "build_timeout": 10, + "cache": [ + { + "location": "", + "modes": [], + "type": "NO_CACHE" + } + ], + "description": "Deploy environment configuration", + "encryption_key": "arn:aws:kms:eu-west-1:719261439472:alias/aws/s3", + "environment": [ + { + "certificate": "", + "compute_type": "BUILD_GENERAL1_SMALL", + "environment_variable": [], + "image": "aws/codebuild/amazonlinux2-x86_64-standard:2.0", + "image_pull_credentials_type": "CODEBUILD", + "privileged_mode": false, + "registry_credential": [], + "type": "LINUX_CONTAINER" + } + ], + "id": "arn:aws:codebuild:eu-west-1:719261439472:project/why-my-project-not-working", + "logs_config": [ + { + "cloudwatch_logs": [ + { + "group_name": "", + "status": "ENABLED", + "stream_name": "" + } + ], + "s3_logs": [ + { + "encryption_disabled": false, + "location": "", + "status": "DISABLED" + } + ] + } + ], + "name": "why-my-project-not-working", + "queued_timeout": 480, + "secondary_artifacts": [], + "secondary_sources": [], + "service_role": "arn:aws:iam::719261439472:role/terra_ci_job", + "source": [ + { + "auth": [], + "buildspec": "version: 0.2\nphases:\n install:\n commands:\n - make install_tools\n build:\n commands:\n - make plan_local resource=$TERRA_CI_RESOURCE\nartifacts:\n files:\n - ./tfplan\n name: $TERRA_CI_BUILD_NAME\n", + "git_clone_depth": 1, + "git_submodules_config": [], + "insecure_ssl": false, + "location": "https://github.com/p0tr3c-terraform/terra-ci-single-account.git", + "report_build_status": false, + "type": "GITHUB" + } + ], + "source_version": "", + "tags": {}, + "vpc_config": [] + }, + "after_unknown": {}, + "before_sensitive": { + "artifacts": [ + {} + ], + "cache": [ + { + "modes": [] + } + ], + "environment": [ + { + "environment_variable": [], + "registry_credential": [] + } + ], + "logs_config": [ + { + "cloudwatch_logs": [ + {} + ], + "s3_logs": [ + {} + ] + } + ], + "secondary_artifacts": [], + "secondary_sources": [], + "source": [ + { + "auth": [], + "git_submodules_config": [] + } + ], + "tags": {}, + "vpc_config": [] + }, + "after_sensitive": { + "artifacts": [ + {} + ], + "cache": [ + { + "modes": [] + } + ], + "environment": [ + { + "environment_variable": [], + "registry_credential": [] + } + ], + "logs_config": [ + { + "cloudwatch_logs": [ + {} + ], + "s3_logs": [ + {} + ] + } + ], + "secondary_artifacts": [], + "secondary_sources": [], + "source": [ + { + "auth": [], + "git_submodules_config": [] + } + ], + "tags": {}, + "vpc_config": [] + } + } + }, + { + "address": "aws_iam_user.ci", + "mode": "managed", + "type": "aws_iam_user", + "name": "ci", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "no-op" + ], + "before": { + "arn": "arn:aws:iam::719261439472:user/ci", + "force_destroy": false, + "id": "ci", + "name": "ci", + "path": "/", + "permissions_boundary": null, + "tags": {}, + "unique_id": "AIDA2O52SSXYORYI4EPXD" + }, + "after": { + "arn": "arn:aws:iam::719261439472:user/ci", + "force_destroy": false, + "id": "ci", + "name": "ci", + "path": "/", + "permissions_boundary": null, + "tags": {}, + "unique_id": "AIDA2O52SSXYORYI4EPXD" + }, + "after_unknown": {}, + "before_sensitive": { + "tags": {} + }, + "after_sensitive": { + "tags": {} + } + } + } + ], + "prior_state": { + "format_version": "0.1", + "terraform_version": "0.15.0", + "values": { + "root_module": { + "resources": [ + { + "address": "aws_codebuild_project.some_projed", + "mode": "managed", + "type": "aws_codebuild_project", + "name": "some_projed", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "arn": "arn:aws:codebuild:eu-west-1:719261439472:project/why-my-project-not-working", + "artifacts": [ + { + "artifact_identifier": "", + "encryption_disabled": false, + "location": "terra-ci-artifacts-eu-west-1-000002", + "name": "why-my-project-not-working", + "namespace_type": "NONE", + "override_artifact_name": true, + "packaging": "NONE", + "path": "", + "type": "S3" + } + ], + "badge_enabled": false, + "badge_url": "", + "build_timeout": 10, + "cache": [ + { + "location": "", + "modes": [], + "type": "NO_CACHE" + } + ], + "description": "Deploy environment configuration", + "encryption_key": "arn:aws:kms:eu-west-1:719261439472:alias/aws/s3", + "environment": [ + { + "certificate": "", + "compute_type": "BUILD_GENERAL1_SMALL", + "environment_variable": [], + "image": "aws/codebuild/amazonlinux2-x86_64-standard:2.0", + "image_pull_credentials_type": "CODEBUILD", + "privileged_mode": false, + "registry_credential": [], + "type": "LINUX_CONTAINER" + } + ], + "id": "arn:aws:codebuild:eu-west-1:719261439472:project/why-my-project-not-working", + "logs_config": [ + { + "cloudwatch_logs": [ + { + "group_name": "", + "status": "ENABLED", + "stream_name": "" + } + ], + "s3_logs": [ + { + "encryption_disabled": false, + "location": "", + "status": "DISABLED" + } + ] + } + ], + "name": "why-my-project-not-working", + "queued_timeout": 480, + "secondary_artifacts": [], + "secondary_sources": [], + "service_role": "arn:aws:iam::719261439472:role/terra_ci_job", + "source": [ + { + "auth": [], + "buildspec": "version: 0.2\nphases:\n install:\n commands:\n - make install_tools\n build:\n commands:\n - make plan_local resource=$TERRA_CI_RESOURCE\nartifacts:\n files:\n - ./tfplan\n name: $TERRA_CI_BUILD_NAME\n", + "git_clone_depth": 1, + "git_submodules_config": [], + "insecure_ssl": false, + "location": "https://github.com/p0tr3c-terraform/terra-ci-single-account.git", + "report_build_status": false, + "type": "GITHUB" + } + ], + "source_version": "", + "tags": {}, + "vpc_config": [] + }, + "depends_on": [ + "data.template_file.terra_ci" + ] + }, + { + "address": "aws_iam_user.ci", + "mode": "managed", + "type": "aws_iam_user", + "name": "ci", + "provider_name": "registry.terraform.io/hashicorp/aws", + "schema_version": 0, + "values": { + "arn": "arn:aws:iam::719261439472:user/ci", + "force_destroy": false, + "id": "ci", + "name": "ci", + "path": "/", + "permissions_boundary": null, + "tags": {}, + "unique_id": "AIDA2O52SSXYORYI4EPXD" + } + }, + { + "address": "data.template_file.terra_ci", + "mode": "data", + "type": "template_file", + "name": "terra_ci", + "provider_name": "registry.terraform.io/hashicorp/template", + "schema_version": 0, + "values": { + "filename": null, + "id": "71c1f84bdc6733e3bcc0e6a7a4cbcdbaa9725d7238cf8b0cf8fe995a826bf534", + "rendered": "version: 0.2\nphases:\n install:\n commands:\n - make install_tools\n build:\n commands:\n - make plan_local resource=$TERRA_CI_RESOURCE\nartifacts:\n files:\n - ./tfplan\n name: $TERRA_CI_BUILD_NAME\n", + "template": "version: 0.2\nphases:\n install:\n commands:\n - make install_tools\n build:\n commands:\n - make plan_local resource=$TERRA_CI_RESOURCE\nartifacts:\n files:\n - ./tfplan\n name: $TERRA_CI_BUILD_NAME\n", + "vars": null + } + } + ] + } + } + }, + "configuration": { + "provider_config": { + "aws": { + "name": "aws", + "expressions": { + "allowed_account_ids": { + "constant_value": [ + "719261439472" + ] + }, + "assume_role": [ + { + "role_arn": { + "constant_value": "arn:aws:iam::719261439472:role/ci" + } + } + ], + "region": { + "constant_value": "eu-west-1" + } + } + } + }, + "root_module": { + "resources": [ + { + "address": "aws_codebuild_project.some_projed", + "mode": "managed", + "type": "aws_codebuild_project", + "name": "some_projed", + "provider_config_key": "aws", + "expressions": { + "artifacts": [ + { + "encryption_disabled": { + "constant_value": true + }, + "location": { + "constant_value": "terra-ci-artifacts-eu-west-1-000002" + }, + "override_artifact_name": { + "constant_value": true + }, + "type": { + "constant_value": "S3" + } + } + ], + "build_timeout": { + "constant_value": "10" + }, + "description": { + "constant_value": "Deploy environment configuration" + }, + "environment": [ + { + "compute_type": { + "constant_value": "BUILD_GENERAL1_SMALL" + }, + "image": { + "constant_value": "aws/codebuild/amazonlinux2-x86_64-standard:2.0" + }, + "image_pull_credentials_type": { + "constant_value": "CODEBUILD" + }, + "privileged_mode": { + "constant_value": false + }, + "type": { + "constant_value": "LINUX_CONTAINER" + } + } + ], + "logs_config": [ + { + "cloudwatch_logs": [ + { + "status": { + "constant_value": "ENABLED" + } + } + ], + "s3_logs": [ + { + "encryption_disabled": { + "constant_value": false + }, + "status": { + "constant_value": "DISABLED" + } + } + ] + } + ], + "name": { + "constant_value": "why-my-project-not-working" + }, + "service_role": { + "constant_value": "arn:aws:iam::719261439472:role/terra_ci_job" + }, + "source": [ + { + "buildspec": { + "references": [ + "data.template_file.terra_ci" + ] + }, + "git_clone_depth": { + "constant_value": 1 + }, + "insecure_ssl": { + "constant_value": false + }, + "location": { + "constant_value": "https://github.com/p0tr3c-terraform/terra-ci-single-account.git" + }, + "report_build_status": { + "constant_value": false + }, + "type": { + "constant_value": "GITHUB" + } + } + ] + }, + "schema_version": 0 + }, + { + "address": "aws_iam_user.ci", + "mode": "managed", + "type": "aws_iam_user", + "name": "ci", + "provider_config_key": "aws", + "expressions": { + "name": { + "constant_value": "ci" + } + }, + "schema_version": 0 + }, + { + "address": "data.template_file.terra_ci", + "mode": "data", + "type": "template_file", + "name": "terra_ci", + "provider_config_key": "template", + "expressions": { + "template": {} + }, + "schema_version": 0 + } + ], + "variables": { + "AWSServiceRoleForAPIGatewayPresent": { + "default": false + }, + "AWSServiceRoleForAmazonEKSPresent": { + "default": false + }, + "AWSServiceRoleForAutoScalingPresent": { + "default": false + }, + "AWSServiceRoleForOrganizationsPresent": { + "default": false + }, + "AWSServiceRoleForSupportPresent": { + "default": false + }, + "AWSServiceRoleForTrustedAdvisorPresent": { + "default": false + }, + "OrganizationAccountAccessRolePresent": { + "default": false + }, + "aws_account_id": {} + } + } + } +} \ No newline at end of file From ec8333707ab2590518fd0f36454c8636ccbf1061 Mon Sep 17 00:00:00 2001 From: Peleg Admi <129038284+PelegCycode@users.noreply.github.com> Date: Wed, 9 Aug 2023 17:30:54 +0300 Subject: [PATCH 025/257] CM-26097 - Add no-restore flag for SCA (#154) * added disable restore flag to cli * added shortcut for flag * changed flag name * removed cli flag shortcut * minor fix * change flag to no-restore * moved flag reading place * fixed help description * Update cycode/cli/main.py Co-authored-by: Ilya Siamionau * removed unused import * updated readme * remove no restore from scan params * changed declared to specified * renamed flag constant --------- Co-authored-by: Ilya Siamionau --- README.md | 25 +++++++++++++------------ cycode/cli/code_scanner.py | 3 ++- cycode/cli/consts.py | 2 ++ cycode/cli/main.py | 18 +++++++++++++++++- 4 files changed, 34 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 4e3da50a..7cdc5338 100644 --- a/README.md +++ b/README.md @@ -232,18 +232,19 @@ The following are the options and commands available with the Cycode CLI applica The Cycode CLI application offers several types of scans so that you can choose the option that best fits your case. The following are the current options and commands available: -| Option | Description | -|--------------------------------------|----------------------------------------------------------------------------| -| `-t, --scan-type [secret\|iac\|sca\|sast]` | Specify the scan you wish to execute (`secret`/`iac`/`sca`/`sast`), the default is `secret` | -| `--secret TEXT` | Specify a Cycode client secret for this specific scan execution | -| `--client-id TEXT` | Specify a Cycode client ID for this specific scan execution | -| `--show-secret BOOLEAN` | Show secrets in plain text. See [Show/Hide Secrets](#showhide-secrets) section for more details. | -| `--soft-fail BOOLEAN` | Run scan without failing, always return a non-error status code. See [Soft Fail](#soft-fail) section for more details. | -| `--severity-threshold [INFO\|LOW\|MEDIUM\|HIGH\|CRITICAL]` | Show only violations at the specified level or higher (supported for the SCA scan type only). | -| `--sca-scan` | Specify the SCA scan you wish to execute (`package-vulnerabilities`/`license-compliance`). The default is both | -| `--monitor` | When specified, the scan results will be recorded in the knowledge graph. Please note that when working in `monitor` mode, the knowledge graph will not be updated as a result of SCM events (Push, Repo creation). (Supported for SCA scan type only). | -| `--report` | When specified, a violations report will be generated. A URL link to the report will be printed as an output to the command execution | -| `--help` | Show options for given command. | +| Option | Description | +|------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `-t, --scan-type [secret\|iac\|sca\|sast]` | Specify the scan you wish to execute (`secret`/`iac`/`sca`/`sast`), the default is `secret` | +| `--secret TEXT` | Specify a Cycode client secret for this specific scan execution | +| `--client-id TEXT` | Specify a Cycode client ID for this specific scan execution | +| `--show-secret BOOLEAN` | Show secrets in plain text. See [Show/Hide Secrets](#showhide-secrets) section for more details. | +| `--soft-fail BOOLEAN` | Run scan without failing, always return a non-error status code. See [Soft Fail](#soft-fail) section for more details. | +| `--severity-threshold [INFO\|LOW\|MEDIUM\|HIGH\|CRITICAL]` | Show only violations at the specified level or higher (supported for the SCA scan type only). | +| `--sca-scan` | Specify the SCA scan you wish to execute (`package-vulnerabilities`/`license-compliance`). The default is both | +| `--monitor` | When specified, the scan results will be recorded in the knowledge graph. Please note that when working in `monitor` mode, the knowledge graph will not be updated as a result of SCM events (Push, Repo creation). (Supported for SCA scan type only). | +| `--report` | When specified, a violations report will be generated. A URL link to the report will be printed as an output to the command execution | +| `--no-restore` | When specified, Cycode will not run restore command. Will scan direct dependencies ONLY! | +| `--help` | Show options for given command. | | Command | Description | |----------------------------------------|-----------------------------------------------------------------| diff --git a/cycode/cli/code_scanner.py b/cycode/cli/code_scanner.py index 978b4240..f63ebf08 100644 --- a/cycode/cli/code_scanner.py +++ b/cycode/cli/code_scanner.py @@ -15,6 +15,7 @@ from cycode.cli import consts from cycode.cli.ci_integrations import get_commit_range from cycode.cli.config import configuration_manager +from cycode.cli.consts import SCA_SKIP_RESTORE_DEPENDENCIES_FLAG from cycode.cli.exceptions import custom_exceptions from cycode.cli.helpers import sca_code_scanner, tf_content_generator from cycode.cli.models import CliError, CliErrors, Document, DocumentDetections, LocalScanResult, Severity @@ -579,7 +580,7 @@ def create_local_scan_result( def perform_pre_scan_documents_actions( context: click.Context, scan_type: str, documents_to_scan: List[Document], is_git_diff: bool = False ) -> None: - if scan_type == consts.SCA_SCAN_TYPE: + if scan_type == consts.SCA_SCAN_TYPE and not context.obj.get(SCA_SKIP_RESTORE_DEPENDENCIES_FLAG): logger.debug('Perform pre scan document add_dependencies_tree_document action') sca_code_scanner.add_dependencies_tree_document(context, documents_to_scan, is_git_diff) diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 76570fde..43046f7f 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -189,3 +189,5 @@ # Example: A -> B -> C # Result: A -> ... -> C SCA_SHORTCUT_DEPENDENCY_PATHS = 2 + +SCA_SKIP_RESTORE_DEPENDENCIES_FLAG = 'no-restore' diff --git a/cycode/cli/main.py b/cycode/cli/main.py index 821251ad..23c211c3 100644 --- a/cycode/cli/main.py +++ b/cycode/cli/main.py @@ -9,7 +9,13 @@ from cycode.cli import code_scanner from cycode.cli.auth.auth_command import authenticate from cycode.cli.config import config -from cycode.cli.consts import CLI_CONTEXT_SETTINGS, ISSUE_DETECTED_STATUS_CODE, NO_ISSUES_STATUS_CODE, PROGRAM_NAME +from cycode.cli.consts import ( + CLI_CONTEXT_SETTINGS, + ISSUE_DETECTED_STATUS_CODE, + NO_ISSUES_STATUS_CODE, + PROGRAM_NAME, + SCA_SKIP_RESTORE_DEPENDENCIES_FLAG, +) from cycode.cli.models import Severity from cycode.cli.user_settings.configuration_manager import ConfigurationManager from cycode.cli.user_settings.credentials_manager import CredentialsManager @@ -99,6 +105,14 @@ type=bool, required=False, ) +@click.option( + f'--{SCA_SKIP_RESTORE_DEPENDENCIES_FLAG}', + is_flag=True, + default=False, + help='When specified, Cycode will not run restore command. Will scan direct dependencies ONLY!', + type=bool, + required=False, +) @click.pass_context def code_scan( context: click.Context, @@ -111,6 +125,7 @@ def code_scan( sca_scan: List[str], monitor: bool, report: bool, + no_restore: bool, ) -> int: """Scans for Secrets, IaC, SCA or SAST violations.""" if show_secret: @@ -128,6 +143,7 @@ def code_scan( context.obj['severity_threshold'] = severity_threshold context.obj['monitor'] = monitor context.obj['report'] = report + context.obj[SCA_SKIP_RESTORE_DEPENDENCIES_FLAG] = no_restore _sca_scan_to_context(context, sca_scan) From b6d966f6b95722625ee9a5812135008a4bb7c792 Mon Sep 17 00:00:00 2001 From: "codesee-maps[bot]" <86324825+codesee-maps[bot]@users.noreply.github.com> Date: Sun, 20 Aug 2023 10:51:26 +0300 Subject: [PATCH 026/257] Install the CodeSee workflow. (#157) Install the CodeSee workflow. Learn more at https://docs.codesee.io Co-authored-by: codesee-maps[bot] <86324825+codesee-maps[bot]@users.noreply.github.com> --- .github/workflows/codesee-arch-diagram.yml | 23 ++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/codesee-arch-diagram.yml diff --git a/.github/workflows/codesee-arch-diagram.yml b/.github/workflows/codesee-arch-diagram.yml new file mode 100644 index 00000000..806d41d1 --- /dev/null +++ b/.github/workflows/codesee-arch-diagram.yml @@ -0,0 +1,23 @@ +# This workflow was added by CodeSee. Learn more at https://codesee.io/ +# This is v2.0 of this workflow file +on: + push: + branches: + - main + pull_request_target: + types: [opened, synchronize, reopened] + +name: CodeSee + +permissions: read-all + +jobs: + codesee: + runs-on: ubuntu-latest + continue-on-error: true + name: Analyze the repo with CodeSee + steps: + - uses: Codesee-io/codesee-action@v2 + with: + codesee-token: ${{ secrets.CODESEE_ARCH_DIAG_API_TOKEN }} + codesee-url: https://app.codesee.io From 39e517187e7f5d3349b549639a4a30d40a447579 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Mon, 21 Aug 2023 16:31:35 +0400 Subject: [PATCH 027/257] CM-23216 - Sign Windows CLI executable (#158) --- .github/workflows/black.yml | 2 ++ .github/workflows/build_executable.yml | 45 +++++++++++++++++++++++++- .github/workflows/pre_release.yml | 2 ++ .github/workflows/release.yml | 2 ++ .github/workflows/ruff.yml | 2 ++ .github/workflows/tests.yml | 2 ++ .github/workflows/tests_full.yml | 2 ++ 7 files changed, 56 insertions(+), 1 deletion(-) diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml index 917994f5..d8003685 100644 --- a/.github/workflows/black.yml +++ b/.github/workflows/black.yml @@ -36,6 +36,8 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' uses: snok/install-poetry@v1 + with: + version: 1.5.1 - name: Add Poetry to PATH run: echo "$HOME/.local/bin" >> $GITHUB_PATH diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index f3690f11..ed88613e 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -51,6 +51,8 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' uses: snok/install-poetry@v1 + with: + version: 1.5.1 - name: Add Poetry to PATH run: echo "$HOME/.local/bin" >> $GITHUB_PATH @@ -108,10 +110,51 @@ jobs: # we can't staple the app because it's executable. we should only staple app bundles like .dmg # xcrun stapler staple dist/cycode - - name: Test signed executable + - name: Test macOS signed executable if: ${{ startsWith(matrix.os, 'macos') }} run: ./dist/cycode version + - name: Import cert for Windows and setup envs + if: ${{ startsWith(matrix.os, 'windows') }} + env: + SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }} + run: | + # import certificate + echo "$SM_CLIENT_CERT_FILE_B64" | base64 --decode > /d/Certificate_pkcs12.p12 + echo "SM_CLIENT_CERT_FILE=D:\\Certificate_pkcs12.p12" >> "$GITHUB_ENV" + + # add required soft to the path + echo "C:\Program Files (x86)\Windows Kits\10\App Certification Kit" >> $GITHUB_PATH + echo "C:\Program Files\DigiCert\DigiCert One Signing Manager Tools" >> $GITHUB_PATH + + - name: Sign Windows executable + if: ${{ startsWith(matrix.os, 'windows') }} + shell: cmd + env: + SM_HOST: ${{ secrets.SM_HOST }} + SM_API_KEY: ${{ secrets.SM_API_KEY }} + SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }} + SM_CODE_SIGNING_CERT_SHA1_HASH: ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }} + run: | + :: setup SSM KSP + curl -X GET https://one.digicert.com/signingmanager/api-ui/v1/releases/smtools-windows-x64.msi/download -H "x-api-key:%SM_API_KEY%" -o smtools-windows-x64.msi + msiexec /i smtools-windows-x64.msi /quiet /qn + C:\Windows\System32\certutil.exe -csp "DigiCert Signing Manager KSP" -key -user + smksp_cert_sync.exe + + :: sign executable + signtool.exe sign /sha1 %SM_CODE_SIGNING_CERT_SHA1_HASH% /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 ".\dist\cycode.exe" + + - name: Test Windows signed executable + if: ${{ startsWith(matrix.os, 'windows') }} + shell: cmd + run: | + :: call executable and expect correct output + .\dist\cycode.exe version + + :: verify signature + signtool.exe verify /v /pa ".\dist\cycode.exe" + - uses: actions/upload-artifact@v3 with: name: cycode-cli-${{ matrix.os }} diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index a2734ca4..342d59bc 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -46,6 +46,8 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' uses: snok/install-poetry@v1 + with: + version: 1.5.1 - name: Add Poetry to PATH run: echo "$HOME/.local/bin" >> $GITHUB_PATH diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 91eeb315..b578f6b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,6 +45,8 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' uses: snok/install-poetry@v1 + with: + version: 1.5.1 - name: Add Poetry to PATH run: echo "$HOME/.local/bin" >> $GITHUB_PATH diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 6ef7cefd..919f4f2d 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -36,6 +36,8 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' uses: snok/install-poetry@v1 + with: + version: 1.5.1 - name: Add Poetry to PATH run: echo "$HOME/.local/bin" >> $GITHUB_PATH diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5b6db7c7..feccb486 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -39,6 +39,8 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' uses: snok/install-poetry@v1 + with: + version: 1.5.1 - name: Add Poetry to PATH run: echo "$HOME/.local/bin" >> $GITHUB_PATH diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index 461e1231..efd8226f 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -52,6 +52,8 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' uses: snok/install-poetry@v1 + with: + version: 1.5.1 - name: Add Poetry to PATH run: echo "$HOME/.local/bin" >> $GITHUB_PATH From b286097011af62cbf2eeef4d320544b82e8bde1b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 26 Aug 2023 02:51:23 +0400 Subject: [PATCH 028/257] Bump gitpython from 3.1.31 to 3.1.32 (#155) --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index e4137a58..df7549f1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -329,13 +329,13 @@ smmap = ">=3.0.1,<6" [[package]] name = "gitpython" -version = "3.1.31" +version = "3.1.32" description = "GitPython is a Python library used to interact with Git repositories" optional = false python-versions = ">=3.7" files = [ - {file = "GitPython-3.1.31-py3-none-any.whl", hash = "sha256:f04893614f6aa713a60cbbe1e6a97403ef633103cdd0ef5eb6efe0deb98dbe8d"}, - {file = "GitPython-3.1.31.tar.gz", hash = "sha256:8ce3bcf69adfdf7c7d503e78fd3b1c492af782d58893b650adb2ac8912ddd573"}, + {file = "GitPython-3.1.32-py3-none-any.whl", hash = "sha256:e3d59b1c2c6ebb9dfa7a184daf3b6dd4914237e7488a1730a6d8f6f5d0b4187f"}, + {file = "GitPython-3.1.32.tar.gz", hash = "sha256:8d9b8cb1e80b9735e8717c9362079d3ce4c6e5ddeebedd0361b228c3a67a62f6"}, ] [package.dependencies] From 7de199a436bdc4762b083645893cf5f95f468449 Mon Sep 17 00:00:00 2001 From: Maor Davidzon <56628808+MaorDavidzon@users.noreply.github.com> Date: Sun, 27 Aug 2023 15:24:23 +0300 Subject: [PATCH 029/257] CM-8818 remove codeSee workflow (#159) --- .github/workflows/codesee-arch-diagram.yml | 23 ---------------------- 1 file changed, 23 deletions(-) delete mode 100644 .github/workflows/codesee-arch-diagram.yml diff --git a/.github/workflows/codesee-arch-diagram.yml b/.github/workflows/codesee-arch-diagram.yml deleted file mode 100644 index 806d41d1..00000000 --- a/.github/workflows/codesee-arch-diagram.yml +++ /dev/null @@ -1,23 +0,0 @@ -# This workflow was added by CodeSee. Learn more at https://codesee.io/ -# This is v2.0 of this workflow file -on: - push: - branches: - - main - pull_request_target: - types: [opened, synchronize, reopened] - -name: CodeSee - -permissions: read-all - -jobs: - codesee: - runs-on: ubuntu-latest - continue-on-error: true - name: Analyze the repo with CodeSee - steps: - - uses: Codesee-io/codesee-action@v2 - with: - codesee-token: ${{ secrets.CODESEE_ARCH_DIAG_API_TOKEN }} - codesee-url: https://app.codesee.io From d934523e58e10baa6db0c07f24bb00c37e55314f Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 12 Sep 2023 10:19:24 +0200 Subject: [PATCH 030/257] CM-27145 - Add setting of logger level for all loggers; fix stream of loggers; fix setting of logger level from env (#162) --- cycode/cli/code_scanner.py | 6 ++--- cycode/cli/consts.py | 2 -- cycode/cli/main.py | 9 +++---- cycode/cyclient/config.py | 54 ++++++++++++++++++++++++-------------- 4 files changed, 40 insertions(+), 31 deletions(-) diff --git a/cycode/cli/code_scanner.py b/cycode/cli/code_scanner.py index f63ebf08..128e8a6f 100644 --- a/cycode/cli/code_scanner.py +++ b/cycode/cli/code_scanner.py @@ -33,13 +33,13 @@ load_json, ) from cycode.cli.utils.progress_bar import ProgressBarSection -from cycode.cli.utils.progress_bar import logger as progress_bar_logger from cycode.cli.utils.scan_batch import run_parallel_batched_scan from cycode.cli.utils.scan_utils import set_issue_detected from cycode.cli.utils.string_utils import get_content_size, is_binary_content from cycode.cli.utils.task_timer import TimeoutAfter from cycode.cli.zip_file import InMemoryZip from cycode.cyclient import logger +from cycode.cyclient.config import set_logging_level from cycode.cyclient.models import Detection, DetectionSchema, DetectionsPerFile, ZippedFileScanResult if TYPE_CHECKING: @@ -1399,9 +1399,7 @@ def perform_post_pre_receive_scan_actions(context: click.Context) -> None: def enable_verbose_mode(context: click.Context) -> None: context.obj['verbose'] = True - # TODO(MarshalX): rework setting the log level for loggers - logger.setLevel(logging.DEBUG) - progress_bar_logger.setLevel(logging.DEBUG) + set_logging_level(logging.DEBUG) def is_verbose_mode_requested_in_pre_receive_scan() -> bool: diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 43046f7f..23b7471a 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -112,8 +112,6 @@ TIMEOUT_ENV_VAR_NAME = 'TIMEOUT' CYCODE_CLI_REQUEST_TIMEOUT_ENV_VAR_NAME = 'CYCODE_CLI_REQUEST_TIMEOUT' LOGGING_LEVEL_ENV_VAR_NAME = 'LOGGING_LEVEL' -# use only for dev envs locally -BATCH_SIZE_ENV_VAR_NAME = 'BATCH_SIZE' VERBOSE_ENV_VAR_NAME = 'CYCODE_CLI_VERBOSE' CYCODE_CONFIGURATION_DIRECTORY: str = '.cycode' diff --git a/cycode/cli/main.py b/cycode/cli/main.py index 23c211c3..94f3ff29 100644 --- a/cycode/cli/main.py +++ b/cycode/cli/main.py @@ -22,8 +22,7 @@ from cycode.cli.user_settings.user_settings_commands import add_exclusions, set_credentials from cycode.cli.utils import scan_utils from cycode.cli.utils.progress_bar import get_progress_bar -from cycode.cli.utils.progress_bar import logger as progress_bar_logger -from cycode.cyclient import logger +from cycode.cyclient.config import set_logging_level from cycode.cyclient.cycode_client_base import CycodeClientBase from cycode.cyclient.models import UserAgentOptionScheme from cycode.cyclient.scan_config.scan_config_creator import create_scan_client @@ -228,10 +227,8 @@ def main_cli( verbose = verbose or configuration_manager.get_verbose_flag() context.obj['verbose'] = verbose - # TODO(MarshalX): rework setting the log level for loggers - log_level = logging.DEBUG if verbose else logging.INFO - logger.setLevel(log_level) - progress_bar_logger.setLevel(log_level) + if verbose: + set_logging_level(logging.DEBUG) context.obj['output'] = output if output == 'json': diff --git a/cycode/cyclient/config.py b/cycode/cyclient/config.py index 2c2f3c00..da3c6a18 100644 --- a/cycode/cyclient/config.py +++ b/cycode/cyclient/config.py @@ -6,17 +6,21 @@ from cycode.cli import consts from cycode.cli.user_settings.configuration_manager import ConfigurationManager +from cycode.cyclient.config_dev import DEV_MODE_ENV_VAR_NAME, DEV_TENANT_ID_ENV_VAR_NAME -# set io encoding (for windows) -from .config_dev import DEV_MODE_ENV_VAR_NAME, DEV_TENANT_ID_ENV_VAR_NAME -sys.stdout.reconfigure(encoding='UTF-8') -sys.stderr.reconfigure(encoding='UTF-8') +def _set_io_encodings() -> None: + # set io encoding (for Windows) + sys.stdout.reconfigure(encoding='UTF-8') + sys.stderr.reconfigure(encoding='UTF-8') + + +_set_io_encodings() # logs logging.basicConfig( - stream=sys.stdout, - level=logging.DEBUG, + stream=sys.stderr, + level=logging.INFO, format='%(asctime)s [%(name)s] %(levelname)s: %(message)s', datefmt='%Y-%m-%d %H:%M:%S', ) @@ -34,19 +38,28 @@ consts.TIMEOUT_ENV_VAR_NAME: 300, consts.LOGGING_LEVEL_ENV_VAR_NAME: logging.INFO, DEV_MODE_ENV_VAR_NAME: 'False', - consts.BATCH_SIZE_ENV_VAR_NAME: 20, } configuration = dict(DEFAULT_CONFIGURATION, **os.environ) +_CREATED_LOGGERS = set() + def get_logger(logger_name: Optional[str] = None) -> logging.Logger: - logger = logging.getLogger(logger_name) - level = _get_val_as_string(consts.LOGGING_LEVEL_ENV_VAR_NAME) - level = level if level in logging._nameToLevel else int(level) - logger.setLevel(level) + config_level = _get_val_as_string(consts.LOGGING_LEVEL_ENV_VAR_NAME) + level = logging.getLevelName(config_level) + + new_logger = logging.getLogger(logger_name) + new_logger.setLevel(level) + + _CREATED_LOGGERS.add(new_logger) - return logger + return new_logger + + +def set_logging_level(level: int) -> None: + for created_logger in _CREATED_LOGGERS: + created_logger.setLevel(level) def _get_val_as_string(key: str) -> str: @@ -66,15 +79,20 @@ def _get_val_as_int(key: str) -> Optional[int]: return None -logger = get_logger('cycode cli') +def _is_valid_url(url: str) -> bool: + try: + urlparse(url) + return True + except ValueError as e: + logger.warning(f'Invalid cycode api url: {url}, using default value', e) + return False + +logger = get_logger('cycode cli') configuration_manager = ConfigurationManager() cycode_api_url = configuration_manager.get_cycode_api_url() -try: - urlparse(cycode_api_url) -except ValueError as e: - logger.warning(f'Invalid cycode api url: {cycode_api_url}, using default value', e) +if not _is_valid_url(cycode_api_url): cycode_api_url = consts.DEFAULT_CYCODE_API_URL timeout = _get_val_as_int(consts.CYCODE_CLI_REQUEST_TIMEOUT_ENV_VAR_NAME) @@ -83,5 +101,3 @@ def _get_val_as_int(key: str) -> Optional[int]: dev_mode = _get_val_as_bool(DEV_MODE_ENV_VAR_NAME) dev_tenant_id = _get_val_as_string(DEV_TENANT_ID_ENV_VAR_NAME) -batch_size = _get_val_as_int(consts.BATCH_SIZE_ENV_VAR_NAME) -verbose = _get_val_as_bool(consts.VERBOSE_ENV_VAR_NAME) From 5d0f6330ccbd2f2ffa93a7207f293e51ee1d30c9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Sep 2023 14:44:28 +0200 Subject: [PATCH 031/257] Bump gitpython from 3.1.32 to 3.1.35 (#163) Bumps [gitpython](https://github.com/gitpython-developers/GitPython) from 3.1.32 to 3.1.35. - [Release notes](https://github.com/gitpython-developers/GitPython/releases) - [Changelog](https://github.com/gitpython-developers/GitPython/blob/main/CHANGES) - [Commits](https://github.com/gitpython-developers/GitPython/compare/3.1.32...3.1.35) --- updated-dependencies: - dependency-name: gitpython dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index df7549f1..5e473450 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. [[package]] name = "altgraph" @@ -329,13 +329,13 @@ smmap = ">=3.0.1,<6" [[package]] name = "gitpython" -version = "3.1.32" +version = "3.1.35" description = "GitPython is a Python library used to interact with Git repositories" optional = false python-versions = ">=3.7" files = [ - {file = "GitPython-3.1.32-py3-none-any.whl", hash = "sha256:e3d59b1c2c6ebb9dfa7a184daf3b6dd4914237e7488a1730a6d8f6f5d0b4187f"}, - {file = "GitPython-3.1.32.tar.gz", hash = "sha256:8d9b8cb1e80b9735e8717c9362079d3ce4c6e5ddeebedd0361b228c3a67a62f6"}, + {file = "GitPython-3.1.35-py3-none-any.whl", hash = "sha256:c19b4292d7a1d3c0f653858db273ff8a6614100d1eb1528b014ec97286193c09"}, + {file = "GitPython-3.1.35.tar.gz", hash = "sha256:9cbefbd1789a5fe9bcf621bb34d3f441f3a90c8461d377f84eda73e721d9b06b"}, ] [package.dependencies] From b9bbd66b04283552e77a7bcb6b2c504a224c7800 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Oct 2023 13:15:18 +0200 Subject: [PATCH 032/257] Bump urllib3 from 1.26.16 to 1.26.17 (#165) Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.26.16 to 1.26.17. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/1.26.16...1.26.17) --- updated-dependencies: - dependency-name: urllib3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 10 +++++----- pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/poetry.lock b/poetry.lock index 5e473450..46104e63 100644 --- a/poetry.lock +++ b/poetry.lock @@ -872,17 +872,17 @@ files = [ [[package]] name = "urllib3" -version = "1.26.16" +version = "1.26.17" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" files = [ - {file = "urllib3-1.26.16-py2.py3-none-any.whl", hash = "sha256:8d36afa7616d8ab714608411b4a3b13e58f463aee519024578e062e141dce20f"}, - {file = "urllib3-1.26.16.tar.gz", hash = "sha256:8f135f6502756bde6b2a9b28989df5fbe87c9970cecaa69041edcce7f0589b14"}, + {file = "urllib3-1.26.17-py2.py3-none-any.whl", hash = "sha256:94a757d178c9be92ef5539b8840d48dc9cf1b2709c9d6b588232a055c524458b"}, + {file = "urllib3-1.26.17.tar.gz", hash = "sha256:24d6a242c28d29af46c3fae832c36db3bbebcc533dd1bb549172cd739c82df21"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] +brotli = ["brotli (==1.0.9)", "brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] @@ -904,4 +904,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = ">=3.7,<3.12" -content-hash = "ca732947ba4a2d16d4697a43b8c81c77cf2f9b73892ba7f8fa80c43703ec8b0b" +content-hash = "c02a52cf933b218c70b12ec1b1cc0cd53ee8d43c3ad3a0ceb0a8fb23a5e0b0c9" diff --git a/pyproject.toml b/pyproject.toml index 93fa2516..e83d552e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ arrow = ">=0.17.0,<0.18.0" binaryornot = ">=0.4.4,<0.5.0" texttable = ">=1.6.7,<1.7.0" requests = ">=2.24,<3.0" -urllib3 = "1.26.16" # lock v1 to avoid issues with openssl and old Python versions (<3.9.11) on macOS +urllib3 = "1.26.17" # lock v1 to avoid issues with openssl and old Python versions (<3.9.11) on macOS [tool.poetry.group.test.dependencies] mock = ">=4.0.3,<4.1.0" From efc42ced14c03d5dcd1e701047f1f09f236593fc Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Fri, 6 Oct 2023 14:56:29 +0200 Subject: [PATCH 033/257] CM-24498 - Add SBOM reports (#164) CM-26214, CM-26215, CM-26216, CM-27640, CM-26254 - Add SBOM reports --- cycode/cli/code_scanner.py | 462 +++--------------- .../{helpers => commands/report}/__init__.py | 0 cycode/cli/commands/report/report_command.py | 23 + .../report/sbom}/__init__.py | 0 cycode/cli/commands/report/sbom/common.py | 94 ++++ .../cli/commands/report/sbom/handle_errors.py | 47 ++ .../cli/commands/report/sbom/sbom_command.py | 84 ++++ .../commands/report/sbom/sbom_path_command.py | 65 +++ .../commands/report/sbom/sbom_report_file.py | 49 ++ .../sbom/sbom_repository_url_command.py | 55 +++ cycode/cli/config.yaml | 4 + cycode/cli/consts.py | 9 + cycode/cli/exceptions/custom_exceptions.py | 4 + .../files_collector}/__init__.py | 0 cycode/cli/files_collector/excluder.py | 134 +++++ cycode/cli/files_collector/iac/__init__.py | 0 .../iac}/tf_content_generator.py | 23 +- cycode/cli/files_collector/models/__init__.py | 0 .../models/in_memory_zip.py} | 14 +- cycode/cli/files_collector/path_documents.py | 112 +++++ .../files_collector/repository_documents.py | 140 ++++++ cycode/cli/files_collector/sca/__init__.py | 0 .../cli/files_collector/sca/maven/__init__.py | 0 .../maven/base_restore_maven_dependencies.py | 0 .../sca}/maven/restore_gradle_dependencies.py | 2 +- .../sca}/maven/restore_maven_dependencies.py | 2 +- .../sca}/sca_code_scanner.py | 14 +- cycode/cli/files_collector/zip_documents.py | 40 ++ cycode/cli/main.py | 32 +- .../user_settings/configuration_manager.py | 7 + cycode/cli/utils/get_api_client.py | 40 ++ cycode/cli/utils/path_utils.py | 40 +- cycode/cli/utils/progress_bar.py | 113 +++-- cycode/cli/utils/scan_batch.py | 6 +- cycode/cyclient/client_creator.py | 23 + cycode/cyclient/models.py | 60 +++ cycode/cyclient/report_client.py | 101 ++++ cycode/cyclient/scan_client.py | 13 +- .../scan_config/scan_config_creator.py | 29 -- .../{scan_config => }/scan_config_base.py | 0 .../cli/helpers/test_tf_content_generator.py | 2 +- tests/cli/test_code_scanner.py | 4 +- tests/conftest.py | 2 +- .../scan_config/test_default_scan_config.py | 2 +- .../scan_config/test_dev_scan_config.py | 2 +- tests/cyclient/test_scan_client.py | 6 +- tests/test_code_scanner.py | 4 +- tests/test_zip_file.py | 6 +- 48 files changed, 1315 insertions(+), 554 deletions(-) rename cycode/cli/{helpers => commands/report}/__init__.py (100%) create mode 100644 cycode/cli/commands/report/report_command.py rename cycode/cli/{helpers/maven => commands/report/sbom}/__init__.py (100%) create mode 100644 cycode/cli/commands/report/sbom/common.py create mode 100644 cycode/cli/commands/report/sbom/handle_errors.py create mode 100644 cycode/cli/commands/report/sbom/sbom_command.py create mode 100644 cycode/cli/commands/report/sbom/sbom_path_command.py create mode 100644 cycode/cli/commands/report/sbom/sbom_report_file.py create mode 100644 cycode/cli/commands/report/sbom/sbom_repository_url_command.py rename cycode/{cyclient/scan_config => cli/files_collector}/__init__.py (100%) create mode 100644 cycode/cli/files_collector/excluder.py create mode 100644 cycode/cli/files_collector/iac/__init__.py rename cycode/cli/{helpers => files_collector/iac}/tf_content_generator.py (73%) create mode 100644 cycode/cli/files_collector/models/__init__.py rename cycode/cli/{zip_file.py => files_collector/models/in_memory_zip.py} (74%) create mode 100644 cycode/cli/files_collector/path_documents.py create mode 100644 cycode/cli/files_collector/repository_documents.py create mode 100644 cycode/cli/files_collector/sca/__init__.py create mode 100644 cycode/cli/files_collector/sca/maven/__init__.py rename cycode/cli/{helpers => files_collector/sca}/maven/base_restore_maven_dependencies.py (100%) rename cycode/cli/{helpers => files_collector/sca}/maven/restore_gradle_dependencies.py (88%) rename cycode/cli/{helpers => files_collector/sca}/maven/restore_maven_dependencies.py (97%) rename cycode/cli/{helpers => files_collector/sca}/sca_code_scanner.py (88%) create mode 100644 cycode/cli/files_collector/zip_documents.py create mode 100644 cycode/cli/utils/get_api_client.py create mode 100644 cycode/cyclient/client_creator.py create mode 100644 cycode/cyclient/report_client.py delete mode 100644 cycode/cyclient/scan_config/scan_config_creator.py rename cycode/cyclient/{scan_config => }/scan_config_base.py (100%) diff --git a/cycode/cli/code_scanner.py b/cycode/cli/code_scanner.py index 128e8a6f..1801d63c 100644 --- a/cycode/cli/code_scanner.py +++ b/cycode/cli/code_scanner.py @@ -5,8 +5,7 @@ import time import traceback from platform import platform -from sys import getsizeof -from typing import TYPE_CHECKING, Callable, Dict, Iterator, List, Optional, Tuple, Union +from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple from uuid import UUID, uuid4 import click @@ -15,39 +14,37 @@ from cycode.cli import consts from cycode.cli.ci_integrations import get_commit_range from cycode.cli.config import configuration_manager -from cycode.cli.consts import SCA_SKIP_RESTORE_DEPENDENCIES_FLAG from cycode.cli.exceptions import custom_exceptions -from cycode.cli.helpers import sca_code_scanner, tf_content_generator +from cycode.cli.files_collector.excluder import exclude_irrelevant_documents_to_scan +from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip +from cycode.cli.files_collector.path_documents import get_relevant_document +from cycode.cli.files_collector.repository_documents import ( + calculate_pre_receive_commit_range, + get_commit_range_modified_documents, + get_diff_file_content, + get_diff_file_path, + get_git_repository_tree_file_entries, + get_pre_commit_modified_documents, + parse_commit_range, +) +from cycode.cli.files_collector.sca import sca_code_scanner +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.models import CliError, CliErrors, Document, DocumentDetections, LocalScanResult, Severity from cycode.cli.printers import ConsolePrinter -from cycode.cli.user_settings.config_file_manager import ConfigFileManager from cycode.cli.utils import scan_utils from cycode.cli.utils.path_utils import ( - change_filename_extension, - get_file_content, - get_file_size, get_path_by_os, - get_relevant_files_in_path, - is_binary_file, - is_sub_path, - load_json, ) -from cycode.cli.utils.progress_bar import ProgressBarSection +from cycode.cli.utils.progress_bar import ScanProgressBarSection from cycode.cli.utils.scan_batch import run_parallel_batched_scan from cycode.cli.utils.scan_utils import set_issue_detected -from cycode.cli.utils.string_utils import get_content_size, is_binary_content from cycode.cli.utils.task_timer import TimeoutAfter -from cycode.cli.zip_file import InMemoryZip from cycode.cyclient import logger from cycode.cyclient.config import set_logging_level from cycode.cyclient.models import Detection, DetectionSchema, DetectionsPerFile, ZippedFileScanResult if TYPE_CHECKING: - from git import Blob, Diff - from git.objects.base import IndexObjUnion - from git.objects.tree import TraversedTreeTup - - from cycode.cli.utils.progress_bar import BaseProgressBar from cycode.cyclient.models import ScanDetailsResponse from cycode.cyclient.scan_client import ScanClient @@ -78,17 +75,17 @@ def scan_repository(context: click.Context, path: str, branch: str) -> None: progress_bar.start() file_entries = list(get_git_repository_tree_file_entries(path, branch)) - progress_bar.set_section_length(ProgressBarSection.PREPARE_LOCAL_FILES, len(file_entries)) + progress_bar.set_section_length(ScanProgressBarSection.PREPARE_LOCAL_FILES, len(file_entries)) documents_to_scan = [] for file in file_entries: # FIXME(MarshalX): probably file could be tree or submodule too. we expect blob only - progress_bar.update(ProgressBarSection.PREPARE_LOCAL_FILES) + progress_bar.update(ScanProgressBarSection.PREPARE_LOCAL_FILES) file_path = file.path if monitor else get_path_by_os(os.path.join(path, file.path)) documents_to_scan.append(Document(file_path, file.data_stream.read().decode('UTF-8', errors='replace'))) - documents_to_scan = exclude_irrelevant_documents_to_scan(context, documents_to_scan) + documents_to_scan = exclude_irrelevant_documents_to_scan(scan_type, documents_to_scan) perform_pre_scan_documents_actions(context, scan_type, documents_to_scan, is_git_diff=False) @@ -140,16 +137,16 @@ def scan_commit_range( total_commits_count = int(repo.git.rev_list('--count', commit_range)) logger.debug(f'Calculating diffs for {total_commits_count} commits in the commit range {commit_range}') - progress_bar.set_section_length(ProgressBarSection.PREPARE_LOCAL_FILES, total_commits_count) + progress_bar.set_section_length(ScanProgressBarSection.PREPARE_LOCAL_FILES, total_commits_count) scanned_commits_count = 0 for commit in repo.iter_commits(rev=commit_range): if _does_reach_to_max_commits_to_scan_limit(commit_ids_to_scan, max_commits_count): logger.debug(f'Reached to max commits to scan count. Going to scan only {max_commits_count} last commits') - progress_bar.update(ProgressBarSection.PREPARE_LOCAL_FILES, total_commits_count - scanned_commits_count) + progress_bar.update(ScanProgressBarSection.PREPARE_LOCAL_FILES, total_commits_count - scanned_commits_count) break - progress_bar.update(ProgressBarSection.PREPARE_LOCAL_FILES) + progress_bar.update(ScanProgressBarSection.PREPARE_LOCAL_FILES) commit_id = commit.hexsha commit_ids_to_scan.append(commit_id) @@ -172,7 +169,7 @@ def scan_commit_range( {'path': path, 'commit_range': commit_range, 'commit_id': commit_id}, ) - documents_to_scan.extend(exclude_irrelevant_documents_to_scan(context, commit_documents_to_scan)) + documents_to_scan.extend(exclude_irrelevant_documents_to_scan(scan_type, commit_documents_to_scan)) scanned_commits_count += 1 logger.debug('List of commit ids to scan, %s', {'commit_ids': commit_ids_to_scan}) @@ -199,30 +196,7 @@ def scan_path(context: click.Context, path: str) -> None: progress_bar.start() logger.debug('Starting path scan process, %s', {'path': path}) - - all_files_to_scan = get_relevant_files_in_path(path=path, exclude_patterns=['**/.git/**', '**/.cycode/**']) - - # we are double the progress bar section length because we are going to process the files twice - # first time to get the file list with respect of excluded patterns (excluding takes seconds to execute) - # second time to get the files content - progress_bar_section_len = len(all_files_to_scan) * 2 - progress_bar.set_section_length(ProgressBarSection.PREPARE_LOCAL_FILES, progress_bar_section_len) - - relevant_files_to_scan = exclude_irrelevant_files(context, all_files_to_scan) - - # after finishing the first processing (excluding), - # we must update the progress bar stage with respect of excluded files. - # now it's possible that we will not process x2 of the files count - # because some of them were excluded, we should subtract the excluded files count - # from the progress bar section length - excluded_files_count = len(all_files_to_scan) - len(relevant_files_to_scan) - progress_bar_section_len = progress_bar_section_len - excluded_files_count - progress_bar.set_section_length(ProgressBarSection.PREPARE_LOCAL_FILES, progress_bar_section_len) - - logger.debug( - 'Found all relevant files for scanning %s', {'path': path, 'file_to_scan_count': len(relevant_files_to_scan)} - ) - scan_disk_files(context, path, relevant_files_to_scan) + scan_disk_files(context, path) @click.command(short_help='Use this command to scan any content that was not committed yet.') @@ -240,14 +214,14 @@ def pre_commit_scan(context: click.Context, ignored_args: List[str]) -> None: diff_files = Repo(os.getcwd()).index.diff('HEAD', create_patch=True, R=True) - progress_bar.set_section_length(ProgressBarSection.PREPARE_LOCAL_FILES, len(diff_files)) + progress_bar.set_section_length(ScanProgressBarSection.PREPARE_LOCAL_FILES, len(diff_files)) documents_to_scan = [] for file in diff_files: - progress_bar.update(ProgressBarSection.PREPARE_LOCAL_FILES) + progress_bar.update(ScanProgressBarSection.PREPARE_LOCAL_FILES) documents_to_scan.append(Document(get_path_by_os(get_diff_file_path(file)), get_diff_file_content(file))) - documents_to_scan = exclude_irrelevant_documents_to_scan(context, documents_to_scan) + documents_to_scan = exclude_irrelevant_documents_to_scan(scan_type, documents_to_scan) scan_documents(context, documents_to_scan, is_git_diff=True) @@ -293,10 +267,13 @@ def pre_receive_scan(context: click.Context, ignored_args: List[str]) -> None: def scan_sca_pre_commit(context: click.Context) -> None: + scan_type = context.obj['scan_type'] scan_parameters = get_default_scan_parameters(context) - git_head_documents, pre_committed_documents = get_pre_commit_modified_documents(context.obj['progress_bar']) - git_head_documents = exclude_irrelevant_documents_to_scan(context, git_head_documents) - pre_committed_documents = exclude_irrelevant_documents_to_scan(context, pre_committed_documents) + git_head_documents, pre_committed_documents = get_pre_commit_modified_documents( + context.obj['progress_bar'], ScanProgressBarSection.PREPARE_LOCAL_FILES + ) + git_head_documents = exclude_irrelevant_documents_to_scan(scan_type, git_head_documents) + pre_committed_documents = exclude_irrelevant_documents_to_scan(scan_type, pre_committed_documents) sca_code_scanner.perform_pre_hook_range_scan_actions(git_head_documents, pre_committed_documents) scan_commit_range_documents( context, @@ -308,15 +285,16 @@ def scan_sca_pre_commit(context: click.Context) -> None: def scan_sca_commit_range(context: click.Context, path: str, commit_range: str) -> None: + scan_type = context.obj['scan_type'] progress_bar = context.obj['progress_bar'] scan_parameters = get_scan_parameters(context, path) from_commit_rev, to_commit_rev = parse_commit_range(commit_range, path) from_commit_documents, to_commit_documents = get_commit_range_modified_documents( - progress_bar, path, from_commit_rev, to_commit_rev + progress_bar, ScanProgressBarSection.PREPARE_LOCAL_FILES, path, from_commit_rev, to_commit_rev ) - from_commit_documents = exclude_irrelevant_documents_to_scan(context, from_commit_documents) - to_commit_documents = exclude_irrelevant_documents_to_scan(context, to_commit_documents) + from_commit_documents = exclude_irrelevant_documents_to_scan(scan_type, from_commit_documents) + to_commit_documents = exclude_irrelevant_documents_to_scan(scan_type, to_commit_documents) sca_code_scanner.perform_pre_commit_range_scan_actions( path, from_commit_documents, from_commit_rev, to_commit_documents, to_commit_rev ) @@ -324,27 +302,15 @@ def scan_sca_commit_range(context: click.Context, path: str, commit_range: str) scan_commit_range_documents(context, from_commit_documents, to_commit_documents, scan_parameters=scan_parameters) -def scan_disk_files(context: click.Context, path: str, files_to_scan: List[str]) -> None: +def scan_disk_files(context: click.Context, path: str) -> None: scan_parameters = get_scan_parameters(context, path) scan_type = context.obj['scan_type'] progress_bar = context.obj['progress_bar'] - is_git_diff = False - try: - documents: List[Document] = [] - for file in files_to_scan: - progress_bar.update(ProgressBarSection.PREPARE_LOCAL_FILES) - - content = get_file_content(file) - if not content: - continue - - documents.append(_generate_document(file, scan_type, content, is_git_diff)) - - perform_pre_scan_documents_actions(context, scan_type, documents, is_git_diff) - scan_documents(context, documents, is_git_diff=is_git_diff, scan_parameters=scan_parameters) - + documents = get_relevant_document(progress_bar, ScanProgressBarSection.PREPARE_LOCAL_FILES, scan_type, path) + perform_pre_scan_documents_actions(context, scan_type, documents) + scan_documents(context, documents, scan_parameters=scan_parameters) except Exception as e: _handle_exception(context, e) @@ -370,8 +336,8 @@ def _scan_batch_thread_func(batch: List[Document]) -> Tuple[str, CliError, Local try: logger.debug('Preparing local files, %s', {'batch_size': len(batch)}) - zipped_documents = zip_documents_to_scan(scan_type, InMemoryZip(), batch) - zip_file_size = getsizeof(zipped_documents.in_memory_zip) + zipped_documents = zip_documents(scan_type, batch) + zip_file_size = zipped_documents.size scan_result = perform_scan( cycode_client, zipped_documents, scan_type, scan_id, is_git_diff, is_commit_range, scan_parameters @@ -442,8 +408,8 @@ def scan_documents( scan_batch_thread_func, documents_to_scan, progress_bar=progress_bar ) - progress_bar.set_section_length(ProgressBarSection.GENERATE_REPORT, 1) - progress_bar.update(ProgressBarSection.GENERATE_REPORT) + progress_bar.set_section_length(ScanProgressBarSection.GENERATE_REPORT, 1) + progress_bar.update(ScanProgressBarSection.GENERATE_REPORT) progress_bar.stop() set_issue_detected_by_scan_results(context, local_scan_results) @@ -471,19 +437,15 @@ def scan_commit_range_documents( to_commit_zipped_documents = InMemoryZip() try: - progress_bar.set_section_length(ProgressBarSection.SCAN, 1) + progress_bar.set_section_length(ScanProgressBarSection.SCAN, 1) scan_result = init_default_scan_result(scan_id) if should_scan_documents(from_documents_to_scan, to_documents_to_scan): logger.debug('Preparing from-commit zip') - from_commit_zipped_documents = zip_documents_to_scan( - scan_type, from_commit_zipped_documents, from_documents_to_scan - ) + from_commit_zipped_documents = zip_documents(scan_type, from_documents_to_scan) logger.debug('Preparing to-commit zip') - to_commit_zipped_documents = zip_documents_to_scan( - scan_type, to_commit_zipped_documents, to_documents_to_scan - ) + to_commit_zipped_documents = zip_documents(scan_type, to_documents_to_scan) scan_result = perform_commit_range_scan_async( cycode_client, @@ -494,15 +456,15 @@ def scan_commit_range_documents( timeout, ) - progress_bar.update(ProgressBarSection.SCAN) - progress_bar.set_section_length(ProgressBarSection.GENERATE_REPORT, 1) + progress_bar.update(ScanProgressBarSection.SCAN) + progress_bar.set_section_length(ScanProgressBarSection.GENERATE_REPORT, 1) local_scan_result = create_local_scan_result( scan_result, to_documents_to_scan, scan_command_type, scan_type, severity_threshold ) set_issue_detected_by_scan_results(context, [local_scan_result]) - progress_bar.update(ProgressBarSection.GENERATE_REPORT) + progress_bar.update(ScanProgressBarSection.GENERATE_REPORT) progress_bar.stop() # errors will be handled with try-except block; printing will not occur on errors @@ -513,9 +475,7 @@ def scan_commit_range_documents( _handle_exception(context, e) error_message = str(e) - zip_file_size = getsizeof(from_commit_zipped_documents.in_memory_zip) + getsizeof( - to_commit_zipped_documents.in_memory_zip - ) + zip_file_size = from_commit_zipped_documents.size + to_commit_zipped_documents.size detections_count = relevant_detections_count = 0 if local_scan_result: @@ -577,45 +537,9 @@ def create_local_scan_result( ) -def perform_pre_scan_documents_actions( - context: click.Context, scan_type: str, documents_to_scan: List[Document], is_git_diff: bool = False -) -> None: - if scan_type == consts.SCA_SCAN_TYPE and not context.obj.get(SCA_SKIP_RESTORE_DEPENDENCIES_FLAG): - logger.debug('Perform pre scan document add_dependencies_tree_document action') - sca_code_scanner.add_dependencies_tree_document(context, documents_to_scan, is_git_diff) - - -def zip_documents_to_scan(scan_type: str, zip_file: InMemoryZip, documents: List[Document]) -> InMemoryZip: - start_zip_creation_time = time.time() - - for index, document in enumerate(documents): - zip_file_size = getsizeof(zip_file.in_memory_zip) - validate_zip_file_size(scan_type, zip_file_size) - - logger.debug( - 'adding file to zip, %s', {'index': index, 'filename': document.path, 'unique_id': document.unique_id} - ) - zip_file.append(document.path, document.unique_id, document.content) - zip_file.close() - - end_zip_creation_time = time.time() - zip_creation_time = int(end_zip_creation_time - start_zip_creation_time) - logger.debug('finished to create zip file, %s', {'zip_creation_time': zip_creation_time}) - return zip_file - - -def validate_zip_file_size(scan_type: str, zip_file_size: int) -> None: - if scan_type == consts.SCA_SCAN_TYPE: - if zip_file_size > consts.SCA_ZIP_MAX_SIZE_LIMIT_IN_BYTES: - raise custom_exceptions.ZipTooLargeError(consts.SCA_ZIP_MAX_SIZE_LIMIT_IN_BYTES) - else: - if zip_file_size > consts.ZIP_MAX_SIZE_LIMIT_IN_BYTES: - raise custom_exceptions.ZipTooLargeError(consts.ZIP_MAX_SIZE_LIMIT_IN_BYTES) - - def perform_scan( cycode_client: 'ScanClient', - zipped_documents: InMemoryZip, + zipped_documents: 'InMemoryZip', scan_type: str, scan_id: str, is_git_diff: bool, @@ -632,7 +556,7 @@ def perform_scan( def perform_scan_async( - cycode_client: 'ScanClient', zipped_documents: InMemoryZip, scan_type: str, scan_parameters: dict + cycode_client: 'ScanClient', zipped_documents: 'InMemoryZip', scan_type: str, scan_parameters: dict ) -> ZippedFileScanResult: scan_async_result = cycode_client.zipped_file_scan_async(zipped_documents, scan_type, scan_parameters) logger.debug('scan request has been triggered successfully, scan id: %s', scan_async_result.scan_id) @@ -642,8 +566,8 @@ def perform_scan_async( def perform_commit_range_scan_async( cycode_client: 'ScanClient', - from_commit_zipped_documents: InMemoryZip, - to_commit_zipped_documents: InMemoryZip, + from_commit_zipped_documents: 'InMemoryZip', + to_commit_zipped_documents: 'InMemoryZip', scan_type: str, scan_parameters: dict, timeout: Optional[int] = None, @@ -759,56 +683,6 @@ def parse_pre_receive_input() -> str: return pre_receive_input.splitlines()[0] -def calculate_pre_receive_commit_range(branch_update_details: str) -> Optional[str]: - end_commit = get_end_commit_from_branch_update_details(branch_update_details) - - # branch is deleted, no need to perform scan - if end_commit == consts.EMPTY_COMMIT_SHA: - return None - - start_commit = get_oldest_unupdated_commit_for_branch(end_commit) - - # no new commit to update found - if not start_commit: - return None - - return f'{start_commit}~1...{end_commit}' - - -def get_end_commit_from_branch_update_details(update_details: str) -> str: - # update details pattern: - _, end_commit, _ = update_details.split() - return end_commit - - -def get_oldest_unupdated_commit_for_branch(commit: str) -> Optional[str]: - # get a list of commits by chronological order that are not in the remote repository yet - # more info about rev-list command: https://git-scm.com/docs/git-rev-list - not_updated_commits = Repo(os.getcwd()).git.rev_list(commit, '--topo-order', '--reverse', '--not', '--all') - commits = not_updated_commits.splitlines() - if not commits: - return None - return commits[0] - - -def get_diff_file_path(file: 'Diff') -> Optional[str]: - return file.b_path if file.b_path else file.a_path - - -def get_diff_file_content(file: 'Diff') -> str: - return file.diff.decode('UTF-8', errors='replace') - - -def should_process_git_object(obj: 'Blob', _: int) -> bool: - return obj.type == 'blob' and obj.size > 0 - - -def get_git_repository_tree_file_entries( - path: str, branch: str -) -> Union[Iterator['IndexObjUnion'], Iterator['TraversedTreeTup']]: - return Repo(path).tree(branch).traverse(predicate=should_process_git_object) - - def get_default_scan_parameters(context: click.Context) -> dict: return { 'monitor': context.obj.get('monitor'), @@ -839,34 +713,6 @@ def try_get_git_remote_url(path: str) -> Optional[dict]: return None -def exclude_irrelevant_documents_to_scan(context: click.Context, documents_to_scan: List[Document]) -> List[Document]: - logger.debug('Excluding irrelevant documents to scan') - - scan_type = context.obj['scan_type'] - - relevant_documents = [] - for document in documents_to_scan: - if _is_relevant_document_to_scan(scan_type, document.path, document.content): - relevant_documents.append(document) - - return relevant_documents - - -def exclude_irrelevant_files(context: click.Context, filenames: List[str]) -> List[str]: - scan_type = context.obj['scan_type'] - progress_bar = context.obj['progress_bar'] - - relevant_files = [] - for filename in filenames: - progress_bar.update(ProgressBarSection.PREPARE_LOCAL_FILES) - if _is_relevant_file_to_scan(scan_type, filename): - relevant_files.append(filename) - - is_sub_path.cache_clear() # free up memory - - return relevant_files - - def exclude_irrelevant_detections( detections: List[Detection], scan_type: str, command_scan_type: str, severity_threshold: str ) -> List[Detection]: @@ -916,60 +762,6 @@ def _exclude_detections_by_exclusions_configuration(detections: List[Detection], return [detection for detection in detections if not _should_exclude_detection(detection, exclusions)] -def get_pre_commit_modified_documents(progress_bar: 'BaseProgressBar') -> Tuple[List[Document], List[Document]]: - git_head_documents = [] - pre_committed_documents = [] - - repo = Repo(os.getcwd()) - diff_files = repo.index.diff(consts.GIT_HEAD_COMMIT_REV, create_patch=True, R=True) - progress_bar.set_section_length(ProgressBarSection.PREPARE_LOCAL_FILES, len(diff_files)) - for file in diff_files: - progress_bar.update(ProgressBarSection.PREPARE_LOCAL_FILES) - - diff_file_path = get_diff_file_path(file) - file_path = get_path_by_os(diff_file_path) - - file_content = sca_code_scanner.get_file_content_from_commit(repo, consts.GIT_HEAD_COMMIT_REV, diff_file_path) - if file_content is not None: - git_head_documents.append(Document(file_path, file_content)) - - if os.path.exists(file_path): - file_content = get_file_content(file_path) - pre_committed_documents.append(Document(file_path, file_content)) - - return git_head_documents, pre_committed_documents - - -def get_commit_range_modified_documents( - progress_bar: 'BaseProgressBar', path: str, from_commit_rev: str, to_commit_rev: str -) -> Tuple[List[Document], List[Document]]: - from_commit_documents = [] - to_commit_documents = [] - - repo = Repo(path) - diff = repo.commit(from_commit_rev).diff(to_commit_rev) - - modified_files_diff = [ - change for change in diff if change.change_type != consts.COMMIT_DIFF_DELETED_FILE_CHANGE_TYPE - ] - progress_bar.set_section_length(ProgressBarSection.PREPARE_LOCAL_FILES, len(modified_files_diff)) - for blob in modified_files_diff: - progress_bar.update(ProgressBarSection.PREPARE_LOCAL_FILES) - - diff_file_path = get_diff_file_path(blob) - file_path = get_path_by_os(diff_file_path) - - file_content = sca_code_scanner.get_file_content_from_commit(repo, from_commit_rev, diff_file_path) - if file_content is not None: - from_commit_documents.append(Document(file_path, file_content)) - - file_content = sca_code_scanner.get_file_content_from_commit(repo, to_commit_rev, diff_file_path) - if file_content is not None: - to_commit_documents.append(Document(file_path, file_content)) - - return from_commit_documents, to_commit_documents - - def _should_exclude_detection(detection: Detection, exclusions: Dict) -> bool: exclusions_by_value = exclusions.get(consts.EXCLUSIONS_BY_VALUE_SECTION_NAME, []) if _is_detection_sha_configured_in_exclusions(detection, exclusions_by_value): @@ -1014,13 +806,6 @@ def _is_detection_sha_configured_in_exclusions(detection: Detection, exclusions: return detection_sha in exclusions -def _is_path_configured_in_exclusions(scan_type: str, file_path: str) -> bool: - exclusions_by_path = configuration_manager.get_exclusions_by_scan_type(scan_type).get( - consts.EXCLUSIONS_BY_PATH_SECTION_NAME, [] - ) - return any(is_sub_path(exclusion_path, file_path) for exclusion_path in exclusions_by_path) - - def _get_package_name(detection: Detection) -> str: package_name = detection.detection_details.get('vulnerable_component', '') package_version = detection.detection_details.get('vulnerable_component_version', '') @@ -1032,119 +817,6 @@ def _get_package_name(detection: Detection) -> str: return f'{package_name}@{package_version}' -def _is_file_relevant_for_sca_scan(filename: str) -> bool: - if any(sca_excluded_path in filename for sca_excluded_path in consts.SCA_EXCLUDED_PATHS): - logger.debug("file is irrelevant because it is from node_modules's inner path, %s", {'filename': filename}) - return False - - return True - - -def _is_relevant_file_to_scan(scan_type: str, filename: str) -> bool: - if _is_subpath_of_cycode_configuration_folder(filename): - logger.debug('file is irrelevant because it is in cycode configuration directory, %s', {'filename': filename}) - return False - - if _is_path_configured_in_exclusions(scan_type, filename): - logger.debug('file is irrelevant because the file path is in the ignore paths list, %s', {'filename': filename}) - return False - - if not _is_file_extension_supported(scan_type, filename): - logger.debug('file is irrelevant because the file extension is not supported, %s', {'filename': filename}) - return False - - if is_binary_file(filename): - logger.debug('file is irrelevant because it is binary file, %s', {'filename': filename}) - return False - - if scan_type != consts.SCA_SCAN_TYPE and _does_file_exceed_max_size_limit(filename): - logger.debug('file is irrelevant because its exceeded max size limit, %s', {'filename': filename}) - return False - - if scan_type == consts.SCA_SCAN_TYPE and not _is_file_relevant_for_sca_scan(filename): - return False - - return True - - -def _is_relevant_document_to_scan(scan_type: str, filename: str, content: str) -> bool: - if _is_subpath_of_cycode_configuration_folder(filename): - logger.debug( - 'document is irrelevant because it is in cycode configuration directory, %s', {'filename': filename} - ) - return False - - if _is_path_configured_in_exclusions(scan_type, filename): - logger.debug( - 'document is irrelevant because the document path is in the ignore paths list, %s', {'filename': filename} - ) - return False - - if not _is_file_extension_supported(scan_type, filename): - logger.debug('document is irrelevant because the file extension is not supported, %s', {'filename': filename}) - return False - - if is_binary_content(content): - logger.debug('document is irrelevant because it is binary, %s', {'filename': filename}) - return False - - if scan_type != consts.SCA_SCAN_TYPE and _does_document_exceed_max_size_limit(content): - logger.debug('document is irrelevant because its exceeded max size limit, %s', {'filename': filename}) - return False - return True - - -def _is_file_extension_supported(scan_type: str, filename: str) -> bool: - filename = filename.lower() - - if scan_type == consts.INFRA_CONFIGURATION_SCAN_TYPE: - return filename.endswith(consts.INFRA_CONFIGURATION_SCAN_SUPPORTED_FILES) - - if scan_type == consts.SCA_SCAN_TYPE: - return filename.endswith(consts.SCA_CONFIGURATION_SCAN_SUPPORTED_FILES) - - return not filename.endswith(consts.SECRET_SCAN_FILE_EXTENSIONS_TO_IGNORE) - - -def _generate_document(file: str, scan_type: str, content: str, is_git_diff: bool) -> Document: - if _is_iac(scan_type) and _is_tfplan_file(file, content): - return _handle_tfplan_file(file, content, is_git_diff) - return Document(file, content, is_git_diff) - - -def _handle_tfplan_file(file: str, content: str, is_git_diff: bool) -> Document: - document_name = _generate_tfplan_document_name(file) - tf_content = tf_content_generator.generate_tf_content_from_tfplan(file, content) - return Document(document_name, tf_content, is_git_diff) - - -def _generate_tfplan_document_name(path: str) -> str: - document_name = change_filename_extension(path, 'tf') - timestamp = int(time.time()) - return f'{timestamp}-{document_name}' - - -def _is_iac(scan_type: str) -> bool: - return scan_type == consts.INFRA_CONFIGURATION_SCAN_TYPE - - -def _is_tfplan_file(file: str, content: str) -> bool: - if not file.endswith('.json'): - return False - tf_plan = load_json(content) - if not isinstance(tf_plan, dict): - return False - return 'resource_changes' in tf_plan - - -def _does_file_exceed_max_size_limit(filename: str) -> bool: - return get_file_size(filename) > consts.FILE_MAX_SIZE_LIMIT_IN_BYTES - - -def _does_document_exceed_max_size_limit(content: str) -> bool: - return get_content_size(content) > consts.FILE_MAX_SIZE_LIMIT_IN_BYTES - - def _get_document_by_file_name( documents: List[Document], file_name: str, unique_id: Optional[str] = None ) -> Optional[Document]: @@ -1155,14 +827,6 @@ def _get_document_by_file_name( return None -def _is_subpath_of_cycode_configuration_folder(filename: str) -> bool: - return ( - is_sub_path(configuration_manager.global_config_file_manager.get_config_directory_path(), filename) - or is_sub_path(configuration_manager.local_config_file_manager.get_config_directory_path(), filename) - or filename.endswith(ConfigFileManager.get_config_file_route()) - ) - - def _handle_exception(context: click.Context, e: Exception, *, return_exception: bool = False) -> Optional[CliError]: context.obj['did_fail'] = True @@ -1372,18 +1036,6 @@ def _does_reach_to_max_commits_to_scan_limit(commit_ids: List[str], max_commits_ return len(commit_ids) >= max_commits_count -def parse_commit_range(commit_range: str, path: str) -> Tuple[str, str]: - from_commit_rev = None - to_commit_rev = None - - for commit in Repo(path).iter_commits(rev=commit_range): - if not to_commit_rev: - to_commit_rev = commit.hexsha - from_commit_rev = commit.hexsha - - return from_commit_rev, to_commit_rev - - def _normalize_file_path(path: str) -> str: if path.startswith('/'): return path[1:] diff --git a/cycode/cli/helpers/__init__.py b/cycode/cli/commands/report/__init__.py similarity index 100% rename from cycode/cli/helpers/__init__.py rename to cycode/cli/commands/report/__init__.py diff --git a/cycode/cli/commands/report/report_command.py b/cycode/cli/commands/report/report_command.py new file mode 100644 index 00000000..4722a6b2 --- /dev/null +++ b/cycode/cli/commands/report/report_command.py @@ -0,0 +1,23 @@ +import click + +from cycode.cli.commands.report.sbom.sbom_command import sbom_command +from cycode.cli.utils.get_api_client import get_report_cycode_client +from cycode.cli.utils.progress_bar import SBOM_REPORT_PROGRESS_BAR_SECTIONS, get_progress_bar + + +@click.group( + commands={ + 'sbom': sbom_command, + }, + short_help='Generate report. You`ll need to specify which report type to perform.', +) +@click.pass_context +def report_command( + context: click.Context, +) -> int: + """Generate report.""" + + context.obj['client'] = get_report_cycode_client(hide_response_log=False) # TODO disable log + context.obj['progress_bar'] = get_progress_bar(hidden=False, sections=SBOM_REPORT_PROGRESS_BAR_SECTIONS) + + return 1 diff --git a/cycode/cli/helpers/maven/__init__.py b/cycode/cli/commands/report/sbom/__init__.py similarity index 100% rename from cycode/cli/helpers/maven/__init__.py rename to cycode/cli/commands/report/sbom/__init__.py diff --git a/cycode/cli/commands/report/sbom/common.py b/cycode/cli/commands/report/sbom/common.py new file mode 100644 index 00000000..334e7275 --- /dev/null +++ b/cycode/cli/commands/report/sbom/common.py @@ -0,0 +1,94 @@ +import pathlib +import time +from platform import platform +from typing import TYPE_CHECKING, Optional + +from cycode.cli import consts +from cycode.cli.commands.report.sbom.sbom_report_file import SbomReportFile +from cycode.cli.config import configuration_manager +from cycode.cli.exceptions.custom_exceptions import ReportAsyncError +from cycode.cli.utils.progress_bar import SbomReportProgressBarSection +from cycode.cyclient import logger +from cycode.cyclient.models import ReportExecutionSchema + +if TYPE_CHECKING: + from cycode.cli.utils.progress_bar import BaseProgressBar + from cycode.cyclient.report_client import ReportClient + + +def _poll_report_execution_until_completed( + progress_bar: 'BaseProgressBar', + client: 'ReportClient', + report_execution_id: int, + polling_timeout: Optional[int] = None, +) -> ReportExecutionSchema: + if polling_timeout is None: + polling_timeout = configuration_manager.get_report_polling_timeout_in_seconds() + + end_polling_time = time.time() + polling_timeout + while time.time() < end_polling_time: + report_execution = client.get_report_execution(report_execution_id) + report_label = report_execution.error_message or report_execution.status_message + + progress_bar.update_label(report_label) + + if report_execution.status == consts.REPORT_STATUS_COMPLETED: + return report_execution + + if report_execution.status == consts.REPORT_STATUS_ERROR: + raise ReportAsyncError(f'Error occurred while trying to generate report: {report_label}') + + time.sleep(consts.REPORT_POLLING_WAIT_INTERVAL_IN_SECONDS) + + raise ReportAsyncError(f'Timeout exceeded while waiting for report to complete. Timeout: {polling_timeout} sec.') + + +def send_report_feedback( + client: 'ReportClient', + start_scan_time: float, + report_type: str, + report_command_type: str, + request_report_parameters: dict, + report_execution_id: int, + error_message: Optional[str] = None, + request_zip_file_size: Optional[int] = None, + **kwargs, +) -> None: + try: + request_report_parameters.update(kwargs) + + end_scan_time = time.time() + scan_status = { + 'report_type': report_type, + 'report_command_type': report_command_type, + 'request_report_parameters': request_report_parameters, + 'operation_system': platform(), + 'error_message': error_message, + 'execution_time': int(end_scan_time - start_scan_time), + 'request_zip_file_size': request_zip_file_size, + } + + client.report_status(report_execution_id, scan_status) + except Exception as e: + logger.debug(f'Failed to send report feedback: {e}') + + +def create_sbom_report( + progress_bar: 'BaseProgressBar', + client: 'ReportClient', + report_execution_id: int, + output_file: Optional[pathlib.Path], + output_format: str, +) -> None: + report_execution = _poll_report_execution_until_completed(progress_bar, client, report_execution_id) + + progress_bar.set_section_length(SbomReportProgressBarSection.GENERATION) + + report_path = report_execution.storage_details.path + report_content = client.get_file_content(report_path) + + progress_bar.set_section_length(SbomReportProgressBarSection.RECEIVE_REPORT) + progress_bar.stop() + + sbom_report = SbomReportFile(report_path, output_format, output_file) + sbom_report.write(report_content) diff --git a/cycode/cli/commands/report/sbom/handle_errors.py b/cycode/cli/commands/report/sbom/handle_errors.py new file mode 100644 index 00000000..b9ca9084 --- /dev/null +++ b/cycode/cli/commands/report/sbom/handle_errors.py @@ -0,0 +1,47 @@ +import traceback +from typing import Optional + +import click + +from cycode.cli.exceptions import custom_exceptions +from cycode.cli.models import CliError, CliErrors +from cycode.cli.printers import ConsolePrinter + + +def handle_report_exception(context: click.Context, err: Exception) -> Optional[CliError]: + if context.obj['verbose']: + click.secho(f'Error: {traceback.format_exc()}', fg='red') + + errors: CliErrors = { + custom_exceptions.NetworkError: CliError( + code='cycode_error', + message='Cycode was unable to complete this report. ' + 'Please try again by executing the `cycode report` command', + ), + custom_exceptions.ScanAsyncError: CliError( + code='report_error', + message='Cycode was unable to complete this report. ' + 'Please try again by executing the `cycode report` command', + ), + custom_exceptions.ReportAsyncError: CliError( + code='report_error', + message='Cycode was unable to complete this report. ' + 'Please try again by executing the `cycode report` command', + ), + custom_exceptions.HttpUnauthorizedError: CliError( + code='auth_error', + message='Unable to authenticate to Cycode, your token is either invalid or has expired. ' + 'Please re-generate your token and reconfigure it by running the `cycode configure` command', + ), + } + + if type(err) in errors: + error = errors[type(err)] + + ConsolePrinter(context).print_error(error) + return None + + if isinstance(err, click.ClickException): + raise err + + raise click.ClickException(str(err)) diff --git a/cycode/cli/commands/report/sbom/sbom_command.py b/cycode/cli/commands/report/sbom/sbom_command.py new file mode 100644 index 00000000..ecfd2782 --- /dev/null +++ b/cycode/cli/commands/report/sbom/sbom_command.py @@ -0,0 +1,84 @@ +import pathlib +from typing import Optional + +import click + +from cycode.cli.commands.report.sbom.sbom_path_command import sbom_path_command +from cycode.cli.commands.report.sbom.sbom_repository_url_command import sbom_repository_url_command +from cycode.cli.config import config +from cycode.cyclient.report_client import ReportParameters + + +@click.group( + commands={ + 'path': sbom_path_command, + 'repository_url': sbom_repository_url_command, + }, + short_help='Generate SBOM report for remote repository by url or local directory by path.', +) +@click.option( + '--format', + '-f', + help='SBOM format.', + type=click.Choice(config['scans']['supported_sbom_formats']), + required=True, +) +@click.option( + '--output-format', + '-o', + default='json', + help='Specify the output file format (the default is json).', + type=click.Choice(['json']), + required=False, +) +@click.option( + '--output-file', + help='Output file (the default is autogenerated filename saved to the current directory).', + default=None, + type=click.Path(resolve_path=True, writable=True, path_type=pathlib.Path), + required=False, +) +@click.option( + '--include-vulnerabilities', + is_flag=True, + default=False, + help='Include vulnerabilities.', + type=bool, + required=False, +) +@click.option( + '--include-dev-dependencies', + is_flag=True, + default=False, + help='Include dev dependencies.', + type=bool, + required=False, +) +@click.pass_context +def sbom_command( + context: click.Context, + format: str, + output_format: Optional[str], + output_file: Optional[pathlib.Path], + include_vulnerabilities: bool, + include_dev_dependencies: bool, +) -> int: + """Generate SBOM report.""" + sbom_format_parts = format.split('-') + if len(sbom_format_parts) != 2: + raise click.ClickException('Invalid SBOM format.') + + sbom_format, sbom_format_version = sbom_format_parts + + report_parameters = ReportParameters( + entity_type='SbomCli', + sbom_report_type=sbom_format, + sbom_version=sbom_format_version, + output_format=output_format, + include_vulnerabilities=include_vulnerabilities, + include_dev_dependencies=include_dev_dependencies, + ) + context.obj['report_parameters'] = report_parameters + context.obj['output_file'] = output_file + + return 1 diff --git a/cycode/cli/commands/report/sbom/sbom_path_command.py b/cycode/cli/commands/report/sbom/sbom_path_command.py new file mode 100644 index 00000000..36b9c4d9 --- /dev/null +++ b/cycode/cli/commands/report/sbom/sbom_path_command.py @@ -0,0 +1,65 @@ +import time + +import click + +from cycode.cli import consts +from cycode.cli.commands.report.sbom.common import create_sbom_report, send_report_feedback +from cycode.cli.commands.report.sbom.handle_errors import handle_report_exception +from cycode.cli.files_collector.path_documents import get_relevant_document +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.utils.progress_bar import SbomReportProgressBarSection + + +@click.command(short_help='Generate SBOM report for provided path in the command.') +@click.argument('path', nargs=1, type=click.Path(exists=True, resolve_path=True), required=True) +@click.pass_context +def sbom_path_command(context: click.Context, path: str) -> None: + client = context.obj['client'] + report_parameters = context.obj['report_parameters'] + output_format = report_parameters.output_format + output_file = context.obj['output_file'] + + progress_bar = context.obj['progress_bar'] + progress_bar.start() + + start_scan_time = time.time() + report_execution_id = -1 + + try: + documents = get_relevant_document( + progress_bar, SbomReportProgressBarSection.PREPARE_LOCAL_FILES, consts.SCA_SCAN_TYPE, path + ) + # TODO(MarshalX): combine perform_pre_scan_documents_actions with get_relevant_document. + # unhardcode usage of context in perform_pre_scan_documents_actions + perform_pre_scan_documents_actions(context, consts.SCA_SCAN_TYPE, documents) + + zipped_documents = zip_documents(consts.SCA_SCAN_TYPE, documents) + report_execution = client.request_sbom_report_execution(report_parameters, zip_file=zipped_documents) + report_execution_id = report_execution.id + + create_sbom_report(progress_bar, client, report_execution_id, output_file, output_format) + + send_report_feedback( + client=client, + start_scan_time=start_scan_time, + report_type='SBOM', + report_command_type='path', + request_report_parameters=report_parameters.to_dict(without_entity_type=False), + report_execution_id=report_execution_id, + request_zip_file_size=zipped_documents.size, + ) + except Exception as e: + progress_bar.stop() + + send_report_feedback( + client=client, + start_scan_time=start_scan_time, + report_type='SBOM', + report_command_type='path', + request_report_parameters=report_parameters.to_dict(without_entity_type=False), + report_execution_id=report_execution_id, + error_message=str(e), + ) + + handle_report_exception(context, e) diff --git a/cycode/cli/commands/report/sbom/sbom_report_file.py b/cycode/cli/commands/report/sbom/sbom_report_file.py new file mode 100644 index 00000000..4d58f89f --- /dev/null +++ b/cycode/cli/commands/report/sbom/sbom_report_file.py @@ -0,0 +1,49 @@ +import os +import pathlib +import re +from typing import Optional + +import click + + +class SbomReportFile: + def __init__(self, storage_path: str, output_format: str, output_file: Optional[pathlib.Path]) -> None: + if output_file is None: + output_file = pathlib.Path(storage_path) + + output_ext = f'.{output_format}' + if output_file.suffix != output_ext: + output_file = output_file.with_suffix(output_ext) + + self._file_path = output_file + + def is_exists(self) -> bool: + return self._file_path.exists() + + def _prompt_overwrite(self) -> bool: + return click.confirm(f'File {self._file_path} already exists. Save with a different filename?', default=True) + + def _write(self, content: str) -> None: + with open(self._file_path, 'w', encoding='UTF-8') as f: + f.write(content) + + def _notify_about_saved_file(self) -> None: + click.echo(f'Report saved to {self._file_path}') + + def _find_and_set_unique_filename(self) -> None: + attempt_no = 0 + while self.is_exists(): + attempt_no += 1 + + base, ext = os.path.splitext(self._file_path) + # Remove previous suffix + base = re.sub(r'-\d+$', '', base) + + self._file_path = pathlib.Path(f'{base}-{attempt_no}{ext}') + + def write(self, content: str) -> None: + if self.is_exists() and self._prompt_overwrite(): + self._find_and_set_unique_filename() + + self._write(content) + self._notify_about_saved_file() diff --git a/cycode/cli/commands/report/sbom/sbom_repository_url_command.py b/cycode/cli/commands/report/sbom/sbom_repository_url_command.py new file mode 100644 index 00000000..a3cb2570 --- /dev/null +++ b/cycode/cli/commands/report/sbom/sbom_repository_url_command.py @@ -0,0 +1,55 @@ +import time + +import click + +from cycode.cli.commands.report.sbom.common import create_sbom_report, send_report_feedback +from cycode.cli.commands.report.sbom.handle_errors import handle_report_exception +from cycode.cli.utils.progress_bar import SbomReportProgressBarSection + + +@click.command(short_help='Generate SBOM report for provided repository URI in the command.') +@click.argument('uri', nargs=1, type=str, required=True) +@click.pass_context +def sbom_repository_url_command(context: click.Context, uri: str) -> None: + progress_bar = context.obj['progress_bar'] + progress_bar.start() + progress_bar.set_section_length(SbomReportProgressBarSection.PREPARE_LOCAL_FILES) + + client = context.obj['client'] + report_parameters = context.obj['report_parameters'] + output_file = context.obj['output_file'] + output_format = report_parameters.output_format + + start_scan_time = time.time() + report_execution_id = -1 + + try: + report_execution = client.request_sbom_report_execution(report_parameters, repository_url=uri) + report_execution_id = report_execution.id + + create_sbom_report(progress_bar, client, report_execution_id, output_file, output_format) + + send_report_feedback( + client=client, + start_scan_time=start_scan_time, + report_type='SBOM', + report_command_type='repository_url', + request_report_parameters=report_parameters.to_dict(without_entity_type=False), + report_execution_id=report_execution_id, + repository_uri=uri, + ) + except Exception as e: + progress_bar.stop() + + send_report_feedback( + client=client, + start_scan_time=start_scan_time, + report_type='SBOM', + report_command_type='repository_url', + request_report_parameters=report_parameters.to_dict(without_entity_type=False), + report_execution_id=report_execution_id, + error_message=str(e), + repository_uri=uri, + ) + + handle_report_exception(context, e) diff --git a/cycode/cli/config.yaml b/cycode/cli/config.yaml index 0ffe7abc..875f37c1 100644 --- a/cycode/cli/config.yaml +++ b/cycode/cli/config.yaml @@ -8,6 +8,10 @@ scans: supported_sca_scans: - package-vulnerabilities - license-compliance + supported_sbom_formats: + - spdx-2.2 + - spdx-2.3 + - cyclonedx-1.4 result_printer: default: lines_to_display: 3 diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 23b7471a..9479765e 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -138,6 +138,11 @@ SCAN_BATCH_MAX_PARALLEL_SCANS = 5 SCAN_BATCH_SCANS_PER_CPU = 1 +# report with polling +REPORT_POLLING_WAIT_INTERVAL_IN_SECONDS = 5 +DEFAULT_REPORT_POLLING_TIMEOUT_IN_SECONDS = 600 +REPORT_POLLING_TIMEOUT_IN_SECONDS_ENV_VAR_NAME = 'REPORT_POLLING_TIMEOUT_IN_SECONDS' + # scan with polling SCAN_POLLING_WAIT_INTERVAL_IN_SECONDS = 5 DEFAULT_SCAN_POLLING_TIMEOUT_IN_SECONDS = 3600 @@ -162,6 +167,10 @@ EXCLUDE_DETECTIONS_IN_DELETED_LINES_ENV_VAR_NAME = 'EXCLUDE_DETECTIONS_IN_DELETED_LINES' DEFAULT_EXCLUDE_DETECTIONS_IN_DELETED_LINES = True +# report statuses +REPORT_STATUS_COMPLETED = 'Completed' +REPORT_STATUS_ERROR = 'Failed' + # scan statuses SCAN_STATUS_COMPLETED = 'Completed' SCAN_STATUS_ERROR = 'Error' diff --git a/cycode/cli/exceptions/custom_exceptions.py b/cycode/cli/exceptions/custom_exceptions.py index ea98a0aa..1b218353 100644 --- a/cycode/cli/exceptions/custom_exceptions.py +++ b/cycode/cli/exceptions/custom_exceptions.py @@ -28,6 +28,10 @@ def __str__(self) -> str: return f'error occurred during the scan. error message: {self.error_message}' +class ReportAsyncError(CycodeError): + pass + + class HttpUnauthorizedError(CycodeError): def __init__(self, error_message: str, response: Response) -> None: self.status_code = 401 diff --git a/cycode/cyclient/scan_config/__init__.py b/cycode/cli/files_collector/__init__.py similarity index 100% rename from cycode/cyclient/scan_config/__init__.py rename to cycode/cli/files_collector/__init__.py diff --git a/cycode/cli/files_collector/excluder.py b/cycode/cli/files_collector/excluder.py new file mode 100644 index 00000000..cbbb358f --- /dev/null +++ b/cycode/cli/files_collector/excluder.py @@ -0,0 +1,134 @@ +from typing import TYPE_CHECKING, List + +from cycode.cli import consts +from cycode.cli.config import configuration_manager +from cycode.cli.user_settings.config_file_manager import ConfigFileManager +from cycode.cli.utils.path_utils import get_file_size, is_binary_file, is_sub_path +from cycode.cli.utils.string_utils import get_content_size, is_binary_content +from cycode.cyclient import logger + +if TYPE_CHECKING: + from cycode.cli.models import Document + from cycode.cli.utils.progress_bar import BaseProgressBar, ProgressBarSection + + +def exclude_irrelevant_files( + progress_bar: 'BaseProgressBar', progress_bar_section: 'ProgressBarSection', scan_type: str, filenames: List[str] +) -> List[str]: + relevant_files = [] + for filename in filenames: + progress_bar.update(progress_bar_section) + if _is_relevant_file_to_scan(scan_type, filename): + relevant_files.append(filename) + + is_sub_path.cache_clear() # free up memory + + return relevant_files + + +def exclude_irrelevant_documents_to_scan(scan_type: str, documents_to_scan: List['Document']) -> List['Document']: + logger.debug('Excluding irrelevant documents to scan') + + relevant_documents = [] + for document in documents_to_scan: + if _is_relevant_document_to_scan(scan_type, document.path, document.content): + relevant_documents.append(document) + + return relevant_documents + + +def _is_subpath_of_cycode_configuration_folder(filename: str) -> bool: + return ( + is_sub_path(configuration_manager.global_config_file_manager.get_config_directory_path(), filename) + or is_sub_path(configuration_manager.local_config_file_manager.get_config_directory_path(), filename) + or filename.endswith(ConfigFileManager.get_config_file_route()) + ) + + +def _is_path_configured_in_exclusions(scan_type: str, file_path: str) -> bool: + exclusions_by_path = configuration_manager.get_exclusions_by_scan_type(scan_type).get( + consts.EXCLUSIONS_BY_PATH_SECTION_NAME, [] + ) + return any(is_sub_path(exclusion_path, file_path) for exclusion_path in exclusions_by_path) + + +def _does_file_exceed_max_size_limit(filename: str) -> bool: + return get_file_size(filename) > consts.FILE_MAX_SIZE_LIMIT_IN_BYTES + + +def _does_document_exceed_max_size_limit(content: str) -> bool: + return get_content_size(content) > consts.FILE_MAX_SIZE_LIMIT_IN_BYTES + + +def _is_relevant_file_to_scan(scan_type: str, filename: str) -> bool: + if _is_subpath_of_cycode_configuration_folder(filename): + logger.debug('file is irrelevant because it is in cycode configuration directory, %s', {'filename': filename}) + return False + + if _is_path_configured_in_exclusions(scan_type, filename): + logger.debug('file is irrelevant because the file path is in the ignore paths list, %s', {'filename': filename}) + return False + + if not _is_file_extension_supported(scan_type, filename): + logger.debug('file is irrelevant because the file extension is not supported, %s', {'filename': filename}) + return False + + if is_binary_file(filename): + logger.debug('file is irrelevant because it is binary file, %s', {'filename': filename}) + return False + + if scan_type != consts.SCA_SCAN_TYPE and _does_file_exceed_max_size_limit(filename): + logger.debug('file is irrelevant because its exceeded max size limit, %s', {'filename': filename}) + return False + + if scan_type == consts.SCA_SCAN_TYPE and not _is_file_relevant_for_sca_scan(filename): + return False + + return True + + +def _is_file_relevant_for_sca_scan(filename: str) -> bool: + if any(sca_excluded_path in filename for sca_excluded_path in consts.SCA_EXCLUDED_PATHS): + logger.debug("file is irrelevant because it is from node_modules's inner path, %s", {'filename': filename}) + return False + + return True + + +def _is_relevant_document_to_scan(scan_type: str, filename: str, content: str) -> bool: + if _is_subpath_of_cycode_configuration_folder(filename): + logger.debug( + 'document is irrelevant because it is in cycode configuration directory, %s', {'filename': filename} + ) + return False + + if _is_path_configured_in_exclusions(scan_type, filename): + logger.debug( + 'document is irrelevant because the document path is in the ignore paths list, %s', {'filename': filename} + ) + return False + + if not _is_file_extension_supported(scan_type, filename): + logger.debug('document is irrelevant because the file extension is not supported, %s', {'filename': filename}) + return False + + if is_binary_content(content): + logger.debug('document is irrelevant because it is binary, %s', {'filename': filename}) + return False + + if scan_type != consts.SCA_SCAN_TYPE and _does_document_exceed_max_size_limit(content): + logger.debug('document is irrelevant because its exceeded max size limit, %s', {'filename': filename}) + return False + return True + + +def _is_file_extension_supported(scan_type: str, filename: str) -> bool: + filename = filename.lower() + + if scan_type == consts.INFRA_CONFIGURATION_SCAN_TYPE: + return filename.endswith(consts.INFRA_CONFIGURATION_SCAN_SUPPORTED_FILES) + + if scan_type == consts.SCA_SCAN_TYPE: + return filename.endswith(consts.SCA_CONFIGURATION_SCAN_SUPPORTED_FILES) + + return not filename.endswith(consts.SECRET_SCAN_FILE_EXTENSIONS_TO_IGNORE) diff --git a/cycode/cli/files_collector/iac/__init__.py b/cycode/cli/files_collector/iac/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/helpers/tf_content_generator.py b/cycode/cli/files_collector/iac/tf_content_generator.py similarity index 73% rename from cycode/cli/helpers/tf_content_generator.py rename to cycode/cli/files_collector/iac/tf_content_generator.py index 7594a96f..4df6d827 100644 --- a/cycode/cli/helpers/tf_content_generator.py +++ b/cycode/cli/files_collector/iac/tf_content_generator.py @@ -1,13 +1,34 @@ import json +import time from typing import List +from cycode.cli import consts from cycode.cli.exceptions.custom_exceptions import TfplanKeyError from cycode.cli.models import ResourceChange -from cycode.cli.utils.path_utils import load_json +from cycode.cli.utils.path_utils import change_filename_extension, load_json ACTIONS_TO_OMIT_RESOURCE = ['delete'] +def generate_tfplan_document_name(path: str) -> str: + document_name = change_filename_extension(path, 'tf') + timestamp = int(time.time()) + return f'{timestamp}-{document_name}' + + +def is_iac(scan_type: str) -> bool: + return scan_type == consts.INFRA_CONFIGURATION_SCAN_TYPE + + +def is_tfplan_file(file: str, content: str) -> bool: + if not file.endswith('.json'): + return False + tf_plan = load_json(content) + if not isinstance(tf_plan, dict): + return False + return 'resource_changes' in tf_plan + + def generate_tf_content_from_tfplan(filename: str, tfplan: str) -> str: planned_resources = _extract_resources(tfplan, filename) return _generate_tf_content(planned_resources) diff --git a/cycode/cli/files_collector/models/__init__.py b/cycode/cli/files_collector/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/zip_file.py b/cycode/cli/files_collector/models/in_memory_zip.py similarity index 74% rename from cycode/cli/zip_file.py rename to cycode/cli/files_collector/models/in_memory_zip.py index 7d659c8e..410d00ca 100644 --- a/cycode/cli/zip_file.py +++ b/cycode/cli/files_collector/models/in_memory_zip.py @@ -1,8 +1,10 @@ -import os.path from io import BytesIO +from sys import getsizeof from typing import Optional from zipfile import ZIP_DEFLATED, ZipFile +from cycode.cli.utils.path_utils import concat_unique_id + class InMemoryZip(object): def __init__(self) -> None: @@ -25,10 +27,6 @@ def read(self) -> bytes: self.in_memory_zip.seek(0) return self.in_memory_zip.read() - -def concat_unique_id(filename: str, unique_id: str) -> str: - if filename.startswith(os.sep): - # remove leading slash to join the path correctly - filename = filename[len(os.sep) :] - - return os.path.join(unique_id, filename) + @property + def size(self) -> int: + return getsizeof(self.in_memory_zip) diff --git a/cycode/cli/files_collector/path_documents.py b/cycode/cli/files_collector/path_documents.py new file mode 100644 index 00000000..a0df5ac0 --- /dev/null +++ b/cycode/cli/files_collector/path_documents.py @@ -0,0 +1,112 @@ +import os +from typing import TYPE_CHECKING, Iterable, List + +import pathspec + +from cycode.cli.files_collector.excluder import exclude_irrelevant_files +from cycode.cli.files_collector.iac.tf_content_generator import ( + generate_tf_content_from_tfplan, + generate_tfplan_document_name, + is_iac, + is_tfplan_file, +) +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_absolute_path, get_file_content +from cycode.cyclient import logger + +if TYPE_CHECKING: + from cycode.cli.utils.progress_bar import BaseProgressBar, ProgressBarSection + + +def _get_all_existing_files_in_directory(path: str) -> List[str]: + files: List[str] = [] + + for root, _, filenames in os.walk(path): + for filename in filenames: + files.append(os.path.join(root, filename)) + + return files + + +def _get_relevant_files_in_path(path: str, exclude_patterns: Iterable[str]) -> List[str]: + absolute_path = get_absolute_path(path) + + if not os.path.isfile(absolute_path) and not os.path.isdir(absolute_path): + raise FileNotFoundError(f'the specified path was not found, path: {absolute_path}') + + if os.path.isfile(absolute_path): + return [absolute_path] + + all_file_paths = set(_get_all_existing_files_in_directory(absolute_path)) + + path_spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, exclude_patterns) + excluded_file_paths = set(path_spec.match_files(all_file_paths)) + + relevant_file_paths = all_file_paths - excluded_file_paths + + return [file_path for file_path in relevant_file_paths if os.path.isfile(file_path)] + + +def _get_relevant_files( + progress_bar: 'BaseProgressBar', progress_bar_section: 'ProgressBarSection', scan_type: str, path: str +) -> List[str]: + all_files_to_scan = _get_relevant_files_in_path(path=path, exclude_patterns=['**/.git/**', '**/.cycode/**']) + + # we are double the progress bar section length because we are going to process the files twice + # first time to get the file list with respect of excluded patterns (excluding takes seconds to execute) + # second time to get the files content + progress_bar_section_len = len(all_files_to_scan) * 2 + progress_bar.set_section_length(progress_bar_section, progress_bar_section_len) + + relevant_files_to_scan = exclude_irrelevant_files(progress_bar, progress_bar_section, scan_type, all_files_to_scan) + + # after finishing the first processing (excluding), + # we must update the progress bar stage with respect of excluded files. + # now it's possible that we will not process x2 of the files count + # because some of them were excluded, we should subtract the excluded files count + # from the progress bar section length + excluded_files_count = len(all_files_to_scan) - len(relevant_files_to_scan) + progress_bar_section_len = progress_bar_section_len - excluded_files_count + progress_bar.set_section_length(progress_bar_section, progress_bar_section_len) + + logger.debug( + 'Found all relevant files for scanning %s', {'path': path, 'file_to_scan_count': len(relevant_files_to_scan)} + ) + + return relevant_files_to_scan + + +def _generate_document(file: str, scan_type: str, content: str, is_git_diff: bool) -> Document: + if is_iac(scan_type) and is_tfplan_file(file, content): + return _handle_tfplan_file(file, content, is_git_diff) + + return Document(file, content, is_git_diff) + + +def _handle_tfplan_file(file: str, content: str, is_git_diff: bool) -> Document: + document_name = generate_tfplan_document_name(file) + tf_content = generate_tf_content_from_tfplan(file, content) + return Document(document_name, tf_content, is_git_diff) + + +def get_relevant_document( + progress_bar: 'BaseProgressBar', + progress_bar_section: 'ProgressBarSection', + scan_type: str, + path: str, + *, + is_git_diff: bool = False, +) -> List[Document]: + relevant_files = _get_relevant_files(progress_bar, progress_bar_section, scan_type, path) + + documents: List[Document] = [] + for file in relevant_files: + progress_bar.update(progress_bar_section) + + content = get_file_content(file) + if not content: + continue + + documents.append(_generate_document(file, scan_type, content, is_git_diff)) + + return documents diff --git a/cycode/cli/files_collector/repository_documents.py b/cycode/cli/files_collector/repository_documents.py new file mode 100644 index 00000000..acd9c225 --- /dev/null +++ b/cycode/cli/files_collector/repository_documents.py @@ -0,0 +1,140 @@ +import os +from typing import TYPE_CHECKING, Iterator, List, Optional, Tuple, Union + +from cycode.cli import consts +from cycode.cli.files_collector.sca import sca_code_scanner +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content, get_path_by_os + +if TYPE_CHECKING: + from git import Blob, Diff + from git.objects.base import IndexObjUnion + from git.objects.tree import TraversedTreeTup + + from cycode.cli.utils.progress_bar import BaseProgressBar, ProgressBarSection + +from git import Repo + + +def should_process_git_object(obj: 'Blob', _: int) -> bool: + return obj.type == 'blob' and obj.size > 0 + + +def get_git_repository_tree_file_entries( + path: str, branch: str +) -> Union[Iterator['IndexObjUnion'], Iterator['TraversedTreeTup']]: + return Repo(path).tree(branch).traverse(predicate=should_process_git_object) + + +def parse_commit_range(commit_range: str, path: str) -> Tuple[str, str]: + from_commit_rev = None + to_commit_rev = None + + for commit in Repo(path).iter_commits(rev=commit_range): + if not to_commit_rev: + to_commit_rev = commit.hexsha + from_commit_rev = commit.hexsha + + return from_commit_rev, to_commit_rev + + +def get_diff_file_path(file: 'Diff') -> Optional[str]: + return file.b_path if file.b_path else file.a_path + + +def get_diff_file_content(file: 'Diff') -> str: + return file.diff.decode('UTF-8', errors='replace') + + +def get_pre_commit_modified_documents( + progress_bar: 'BaseProgressBar', progress_bar_section: 'ProgressBarSection' +) -> Tuple[List[Document], List[Document]]: + git_head_documents = [] + pre_committed_documents = [] + + repo = Repo(os.getcwd()) + diff_files = repo.index.diff(consts.GIT_HEAD_COMMIT_REV, create_patch=True, R=True) + progress_bar.set_section_length(progress_bar_section, len(diff_files)) + for file in diff_files: + progress_bar.update(progress_bar_section) + + diff_file_path = get_diff_file_path(file) + file_path = get_path_by_os(diff_file_path) + + file_content = sca_code_scanner.get_file_content_from_commit(repo, consts.GIT_HEAD_COMMIT_REV, diff_file_path) + if file_content is not None: + git_head_documents.append(Document(file_path, file_content)) + + if os.path.exists(file_path): + file_content = get_file_content(file_path) + pre_committed_documents.append(Document(file_path, file_content)) + + return git_head_documents, pre_committed_documents + + +def get_commit_range_modified_documents( + progress_bar: 'BaseProgressBar', + progress_bar_section: 'ProgressBarSection', + path: str, + from_commit_rev: str, + to_commit_rev: str, +) -> Tuple[List[Document], List[Document]]: + from_commit_documents = [] + to_commit_documents = [] + + repo = Repo(path) + diff = repo.commit(from_commit_rev).diff(to_commit_rev) + + modified_files_diff = [ + change for change in diff if change.change_type != consts.COMMIT_DIFF_DELETED_FILE_CHANGE_TYPE + ] + progress_bar.set_section_length(progress_bar_section, len(modified_files_diff)) + for blob in modified_files_diff: + progress_bar.update(progress_bar_section) + + diff_file_path = get_diff_file_path(blob) + file_path = get_path_by_os(diff_file_path) + + file_content = sca_code_scanner.get_file_content_from_commit(repo, from_commit_rev, diff_file_path) + if file_content is not None: + from_commit_documents.append(Document(file_path, file_content)) + + file_content = sca_code_scanner.get_file_content_from_commit(repo, to_commit_rev, diff_file_path) + if file_content is not None: + to_commit_documents.append(Document(file_path, file_content)) + + return from_commit_documents, to_commit_documents + + +def calculate_pre_receive_commit_range(branch_update_details: str) -> Optional[str]: + end_commit = _get_end_commit_from_branch_update_details(branch_update_details) + + # branch is deleted, no need to perform scan + if end_commit == consts.EMPTY_COMMIT_SHA: + return None + + start_commit = _get_oldest_unupdated_commit_for_branch(end_commit) + + # no new commit to update found + if not start_commit: + return None + + return f'{start_commit}~1...{end_commit}' + + +def _get_end_commit_from_branch_update_details(update_details: str) -> str: + # update details pattern: + _, end_commit, _ = update_details.split() + return end_commit + + +def _get_oldest_unupdated_commit_for_branch(commit: str) -> Optional[str]: + # get a list of commits by chronological order that are not in the remote repository yet + # more info about rev-list command: https://git-scm.com/docs/git-rev-list + not_updated_commits = Repo(os.getcwd()).git.rev_list(commit, '--topo-order', '--reverse', '--not', '--all') + + commits = not_updated_commits.splitlines() + if not commits: + return None + + return commits[0] diff --git a/cycode/cli/files_collector/sca/__init__.py b/cycode/cli/files_collector/sca/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/files_collector/sca/maven/__init__.py b/cycode/cli/files_collector/sca/maven/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/helpers/maven/base_restore_maven_dependencies.py b/cycode/cli/files_collector/sca/maven/base_restore_maven_dependencies.py similarity index 100% rename from cycode/cli/helpers/maven/base_restore_maven_dependencies.py rename to cycode/cli/files_collector/sca/maven/base_restore_maven_dependencies.py diff --git a/cycode/cli/helpers/maven/restore_gradle_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py similarity index 88% rename from cycode/cli/helpers/maven/restore_gradle_dependencies.py rename to cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py index f8cd2fec..ef975ba5 100644 --- a/cycode/cli/helpers/maven/restore_gradle_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py @@ -2,7 +2,7 @@ import click -from cycode.cli.helpers.maven.base_restore_maven_dependencies import BaseRestoreMavenDependencies +from cycode.cli.files_collector.sca.maven.base_restore_maven_dependencies import BaseRestoreMavenDependencies from cycode.cli.models import Document BUILD_GRADLE_FILE_NAME = 'build.gradle' diff --git a/cycode/cli/helpers/maven/restore_maven_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py similarity index 97% rename from cycode/cli/helpers/maven/restore_maven_dependencies.py rename to cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py index d8e6675f..0e21df12 100644 --- a/cycode/cli/helpers/maven/restore_maven_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py @@ -3,7 +3,7 @@ import click -from cycode.cli.helpers.maven.base_restore_maven_dependencies import ( +from cycode.cli.files_collector.sca.maven.base_restore_maven_dependencies import ( BaseRestoreMavenDependencies, build_dep_tree_path, execute_command, diff --git a/cycode/cli/helpers/sca_code_scanner.py b/cycode/cli/files_collector/sca/sca_code_scanner.py similarity index 88% rename from cycode/cli/helpers/sca_code_scanner.py rename to cycode/cli/files_collector/sca/sca_code_scanner.py index 227b553e..a6aa6b78 100644 --- a/cycode/cli/helpers/sca_code_scanner.py +++ b/cycode/cli/files_collector/sca/sca_code_scanner.py @@ -5,14 +5,14 @@ from git import GitCommandError, Repo from cycode.cli import consts -from cycode.cli.helpers.maven.restore_gradle_dependencies import RestoreGradleDependencies -from cycode.cli.helpers.maven.restore_maven_dependencies import RestoreMavenDependencies +from cycode.cli.files_collector.sca.maven.restore_gradle_dependencies import RestoreGradleDependencies +from cycode.cli.files_collector.sca.maven.restore_maven_dependencies import RestoreMavenDependencies from cycode.cli.models import Document from cycode.cli.utils.path_utils import get_file_content, get_file_dir, join_paths from cycode.cyclient import logger if TYPE_CHECKING: - from cycode.cli.helpers.maven.base_restore_maven_dependencies import BaseRestoreMavenDependencies + from cycode.cli.files_collector.sca.maven.base_restore_maven_dependencies import BaseRestoreMavenDependencies BUILD_GRADLE_FILE_NAME = 'build.gradle' BUILD_GRADLE_KTS_FILE_NAME = 'build.gradle.kts' @@ -141,3 +141,11 @@ def get_file_content_from_commit(repo: Repo, commit: str, file_path: str) -> Opt return repo.git.show(f'{commit}:{file_path}') except GitCommandError: return None + + +def perform_pre_scan_documents_actions( + context: click.Context, scan_type: str, documents_to_scan: List[Document], is_git_diff: bool = False +) -> None: + if scan_type == consts.SCA_SCAN_TYPE and not context.obj.get(consts.SCA_SKIP_RESTORE_DEPENDENCIES_FLAG): + logger.debug('Perform pre scan document add_dependencies_tree_document action') + add_dependencies_tree_document(context, documents_to_scan, is_git_diff) diff --git a/cycode/cli/files_collector/zip_documents.py b/cycode/cli/files_collector/zip_documents.py new file mode 100644 index 00000000..b2b252f4 --- /dev/null +++ b/cycode/cli/files_collector/zip_documents.py @@ -0,0 +1,40 @@ +import time +from typing import List, Optional + +from cycode.cli import consts +from cycode.cli.exceptions import custom_exceptions +from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip +from cycode.cli.models import Document +from cycode.cyclient import logger + + +def _validate_zip_file_size(scan_type: str, zip_file_size: int) -> None: + if scan_type == consts.SCA_SCAN_TYPE: + if zip_file_size > consts.SCA_ZIP_MAX_SIZE_LIMIT_IN_BYTES: + raise custom_exceptions.ZipTooLargeError(consts.SCA_ZIP_MAX_SIZE_LIMIT_IN_BYTES) + else: + if zip_file_size > consts.ZIP_MAX_SIZE_LIMIT_IN_BYTES: + raise custom_exceptions.ZipTooLargeError(consts.ZIP_MAX_SIZE_LIMIT_IN_BYTES) + + +def zip_documents(scan_type: str, documents: List[Document], zip_file: Optional[InMemoryZip] = None) -> InMemoryZip: + if zip_file is None: + zip_file = InMemoryZip() + + start_zip_creation_time = time.time() + + for index, document in enumerate(documents): + _validate_zip_file_size(scan_type, zip_file.size) + + logger.debug( + 'adding file to zip, %s', {'index': index, 'filename': document.path, 'unique_id': document.unique_id} + ) + zip_file.append(document.path, document.unique_id, document.content) + + zip_file.close() + + end_zip_creation_time = time.time() + zip_creation_time = int(end_zip_creation_time - start_zip_creation_time) + logger.debug('finished to create zip file, %s', {'zip_creation_time': zip_creation_time}) + + return zip_file diff --git a/cycode/cli/main.py b/cycode/cli/main.py index 94f3ff29..efa2b200 100644 --- a/cycode/cli/main.py +++ b/cycode/cli/main.py @@ -1,13 +1,14 @@ import json import logging import sys -from typing import TYPE_CHECKING, List, Optional, Tuple +from typing import List, Optional import click from cycode import __version__ from cycode.cli import code_scanner from cycode.cli.auth.auth_command import authenticate +from cycode.cli.commands.report.report_command import report_command from cycode.cli.config import config from cycode.cli.consts import ( CLI_CONTEXT_SETTINGS, @@ -18,17 +19,13 @@ ) from cycode.cli.models import Severity from cycode.cli.user_settings.configuration_manager import ConfigurationManager -from cycode.cli.user_settings.credentials_manager import CredentialsManager from cycode.cli.user_settings.user_settings_commands import add_exclusions, set_credentials from cycode.cli.utils import scan_utils -from cycode.cli.utils.progress_bar import get_progress_bar +from cycode.cli.utils.get_api_client import get_scan_cycode_client +from cycode.cli.utils.progress_bar import SCAN_PROGRESS_BAR_SECTIONS, get_progress_bar from cycode.cyclient.config import set_logging_level from cycode.cyclient.cycode_client_base import CycodeClientBase from cycode.cyclient.models import UserAgentOptionScheme -from cycode.cyclient.scan_config.scan_config_creator import create_scan_client - -if TYPE_CHECKING: - from cycode.cyclient.scan_client import ScanClient @click.group( @@ -137,7 +134,7 @@ def code_scan( else: context.obj['soft_fail'] = config['soft_fail'] - context.obj['client'] = get_cycode_client(client_id, secret, not context.obj['show_secret']) + context.obj['client'] = get_scan_cycode_client(client_id, secret, not context.obj['show_secret']) context.obj['scan_type'] = scan_type context.obj['severity_threshold'] = severity_threshold context.obj['monitor'] = monitor @@ -185,6 +182,7 @@ def version(context: click.Context) -> None: @click.group( commands={ 'scan': code_scan, + 'report': report_command, 'configure': set_credentials, 'ignore': add_exclusions, 'auth': authenticate, @@ -234,29 +232,13 @@ def main_cli( if output == 'json': no_progress_meter = True - context.obj['progress_bar'] = get_progress_bar(hidden=no_progress_meter) + context.obj['progress_bar'] = get_progress_bar(hidden=no_progress_meter, sections=SCAN_PROGRESS_BAR_SECTIONS) if user_agent: user_agent_option = UserAgentOptionScheme().loads(user_agent) CycodeClientBase.enrich_user_agent(user_agent_option.user_agent_suffix) -def get_cycode_client(client_id: str, client_secret: str, hide_response_log: bool) -> 'ScanClient': - if not client_id or not client_secret: - client_id, client_secret = _get_configured_credentials() - if not client_id: - raise click.ClickException('Cycode client id needed.') - if not client_secret: - raise click.ClickException('Cycode client secret is needed.') - - return create_scan_client(client_id, client_secret, hide_response_log) - - -def _get_configured_credentials() -> Tuple[str, str]: - credentials_manager = CredentialsManager() - return credentials_manager.get_credentials() - - def _should_fail_scan(context: click.Context) -> bool: return scan_utils.is_scan_failed(context) diff --git a/cycode/cli/user_settings/configuration_manager.py b/cycode/cli/user_settings/configuration_manager.py index 98e62e07..65da08fc 100644 --- a/cycode/cli/user_settings/configuration_manager.py +++ b/cycode/cli/user_settings/configuration_manager.py @@ -103,6 +103,13 @@ def get_scan_polling_timeout_in_seconds(self) -> int: ) ) + def get_report_polling_timeout_in_seconds(self) -> int: + return int( + self._get_value_from_environment_variables( + consts.REPORT_POLLING_TIMEOUT_IN_SECONDS_ENV_VAR_NAME, consts.DEFAULT_REPORT_POLLING_TIMEOUT_IN_SECONDS + ) + ) + def get_sca_pre_commit_timeout_in_seconds(self) -> int: return int( self._get_value_from_environment_variables( diff --git a/cycode/cli/utils/get_api_client.py b/cycode/cli/utils/get_api_client.py new file mode 100644 index 00000000..7bbfa2d9 --- /dev/null +++ b/cycode/cli/utils/get_api_client.py @@ -0,0 +1,40 @@ +from typing import TYPE_CHECKING, Optional, Tuple, Union + +import click + +from cycode.cli.user_settings.credentials_manager import CredentialsManager +from cycode.cyclient.client_creator import create_report_client, create_scan_client + +if TYPE_CHECKING: + from cycode.cyclient.report_client import ReportClient + from cycode.cyclient.scan_client import ScanClient + + +def _get_cycode_client( + create_client_func: callable, client_id: Optional[str], client_secret: Optional[str], hide_response_log: bool +) -> Union['ScanClient', 'ReportClient']: + if not client_id or not client_secret: + client_id, client_secret = _get_configured_credentials() + if not client_id: + raise click.ClickException('Cycode client id needed.') + if not client_secret: + raise click.ClickException('Cycode client secret is needed.') + + return create_client_func(client_id, client_secret, hide_response_log) + + +def get_scan_cycode_client( + client_id: Optional[str] = None, client_secret: Optional[str] = None, hide_response_log: bool = True +) -> 'ScanClient': + return _get_cycode_client(create_scan_client, client_id, client_secret, hide_response_log) + + +def get_report_cycode_client( + client_id: Optional[str] = None, client_secret: Optional[str] = None, hide_response_log: bool = True +) -> 'ReportClient': + return _get_cycode_client(create_report_client, client_id, client_secret, hide_response_log) + + +def _get_configured_credentials() -> Tuple[str, str]: + credentials_manager = CredentialsManager() + return credentials_manager.get_credentials() diff --git a/cycode/cli/utils/path_utils.py b/cycode/cli/utils/path_utils.py index ad5ce94e..e0cedc88 100644 --- a/cycode/cli/utils/path_utils.py +++ b/cycode/cli/utils/path_utils.py @@ -1,31 +1,11 @@ import json import os from functools import lru_cache -from typing import AnyStr, Iterable, List, Optional +from typing import AnyStr, List, Optional -import pathspec from binaryornot.check import is_binary -def get_relevant_files_in_path(path: str, exclude_patterns: Iterable[str]) -> List[str]: - absolute_path = get_absolute_path(path) - - if not os.path.isfile(absolute_path) and not os.path.isdir(absolute_path): - raise FileNotFoundError(f'the specified path was not found, path: {absolute_path}') - - if os.path.isfile(absolute_path): - return [absolute_path] - - all_file_paths = set(_get_all_existing_files_in_directory(absolute_path)) - - path_spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, exclude_patterns) - excluded_file_paths = set(path_spec.match_files(all_file_paths)) - - relevant_file_paths = all_file_paths - excluded_file_paths - - return [file_path for file_path in relevant_file_paths if os.path.isfile(file_path)] - - @lru_cache(maxsize=None) def is_sub_path(path: str, sub_path: str) -> bool: try: @@ -54,16 +34,6 @@ def get_path_by_os(filename: str) -> str: return filename.replace('/', os.sep) -def _get_all_existing_files_in_directory(path: str) -> List[str]: - files: List[str] = [] - - for root, _, filenames in os.walk(path): - for filename in filenames: - files.append(os.path.join(root, filename)) - - return files - - def is_path_exists(path: str) -> bool: return os.path.exists(path) @@ -98,3 +68,11 @@ def load_json(txt: str) -> Optional[dict]: def change_filename_extension(filename: str, extension: str) -> str: base_name, _ = os.path.splitext(filename) return f'{base_name}.{extension}' + + +def concat_unique_id(filename: str, unique_id: str) -> str: + if filename.startswith(os.sep): + # remove leading slash to join the path correctly + filename = filename[len(os.sep) :] + + return os.path.join(unique_id, filename) diff --git a/cycode/cli/utils/progress_bar.py b/cycode/cli/utils/progress_bar.py index 083d0715..b0e94d92 100644 --- a/cycode/cli/utils/progress_bar.py +++ b/cycode/cli/utils/progress_bar.py @@ -16,15 +16,11 @@ class ProgressBarSection(AutoCountEnum): - PREPARE_LOCAL_FILES = auto() - SCAN = auto() - GENERATE_REPORT = auto() - def has_next(self) -> bool: - return self.value < len(ProgressBarSection) - 1 + return self.value < len(type(self)) - 1 def next(self) -> 'ProgressBarSection': - return ProgressBarSection(self.value + 1) + return type(self)(self.value + 1) class ProgressBarSectionInfo(NamedTuple): @@ -32,25 +28,62 @@ class ProgressBarSectionInfo(NamedTuple): label: str start_percent: int stop_percent: int + initial: bool = False _PROGRESS_BAR_LENGTH = 100 -_PROGRESS_BAR_SECTIONS = { - ProgressBarSection.PREPARE_LOCAL_FILES: ProgressBarSectionInfo( - ProgressBarSection.PREPARE_LOCAL_FILES, 'Prepare local files', start_percent=0, stop_percent=5 +ProgressBarSections = Dict[ProgressBarSection, ProgressBarSectionInfo] + + +class ScanProgressBarSection(ProgressBarSection): + PREPARE_LOCAL_FILES = auto() + SCAN = auto() + GENERATE_REPORT = auto() + + +SCAN_PROGRESS_BAR_SECTIONS: ProgressBarSections = { + ScanProgressBarSection.PREPARE_LOCAL_FILES: ProgressBarSectionInfo( + ScanProgressBarSection.PREPARE_LOCAL_FILES, 'Prepare local files', start_percent=0, stop_percent=5, initial=True + ), + ScanProgressBarSection.SCAN: ProgressBarSectionInfo( + ScanProgressBarSection.SCAN, 'Scan in progress', start_percent=5, stop_percent=95 + ), + ScanProgressBarSection.GENERATE_REPORT: ProgressBarSectionInfo( + ScanProgressBarSection.GENERATE_REPORT, 'Generate report', start_percent=95, stop_percent=100 + ), +} + + +class SbomReportProgressBarSection(ProgressBarSection): + PREPARE_LOCAL_FILES = auto() + GENERATION = auto() + RECEIVE_REPORT = auto() + + +SBOM_REPORT_PROGRESS_BAR_SECTIONS: ProgressBarSections = { + SbomReportProgressBarSection.PREPARE_LOCAL_FILES: ProgressBarSectionInfo( + SbomReportProgressBarSection.PREPARE_LOCAL_FILES, + 'Prepare local files', + start_percent=0, + stop_percent=30, + initial=True, ), - ProgressBarSection.SCAN: ProgressBarSectionInfo( - ProgressBarSection.SCAN, 'Scan in progress', start_percent=5, stop_percent=95 + SbomReportProgressBarSection.GENERATION: ProgressBarSectionInfo( + SbomReportProgressBarSection.GENERATION, 'Report generation in progress', start_percent=30, stop_percent=90 ), - ProgressBarSection.GENERATE_REPORT: ProgressBarSectionInfo( - ProgressBarSection.GENERATE_REPORT, 'Generate report', start_percent=95, stop_percent=100 + SbomReportProgressBarSection.RECEIVE_REPORT: ProgressBarSectionInfo( + SbomReportProgressBarSection.RECEIVE_REPORT, 'Receive report', start_percent=90, stop_percent=100 ), } -def _get_section_length(section: 'ProgressBarSection') -> int: - return _PROGRESS_BAR_SECTIONS[section].stop_percent - _PROGRESS_BAR_SECTIONS[section].start_percent +def _get_initial_section(progress_bar_sections: ProgressBarSections) -> ProgressBarSectionInfo: + for section in progress_bar_sections.values(): + if section.initial: + return section + + raise ValueError('No initial section found') class BaseProgressBar(ABC): @@ -75,13 +108,17 @@ def stop(self) -> None: ... @abstractmethod - def set_section_length(self, section: 'ProgressBarSection', length: int) -> None: + def set_section_length(self, section: 'ProgressBarSection', length: int = 0) -> None: ... @abstractmethod def update(self, section: 'ProgressBarSection') -> None: ... + @abstractmethod + def update_label(self, label: Optional[str] = None) -> None: + ... + class DummyProgressBar(BaseProgressBar): def __init__(self, *args, **kwargs) -> None: @@ -99,16 +136,22 @@ def start(self) -> None: def stop(self) -> None: pass - def set_section_length(self, section: 'ProgressBarSection', length: int) -> None: + def set_section_length(self, section: 'ProgressBarSection', length: int = 0) -> None: pass def update(self, section: 'ProgressBarSection') -> None: pass + def update_label(self, label: Optional[str] = None) -> None: + pass + class CompositeProgressBar(BaseProgressBar): - def __init__(self) -> None: + def __init__(self, progress_bar_sections: ProgressBarSections) -> None: super().__init__() + + self._progress_bar_sections = progress_bar_sections + self._progress_bar_context_manager = click.progressbar( length=_PROGRESS_BAR_LENGTH, item_show_func=self._progress_bar_item_show_func, @@ -121,7 +164,7 @@ def __init__(self) -> None: self._section_values: Dict[ProgressBarSection, int] = {} self._current_section_value = 0 - self._current_section: ProgressBarSectionInfo = _PROGRESS_BAR_SECTIONS[ProgressBarSection.PREPARE_LOCAL_FILES] + self._current_section: ProgressBarSectionInfo = _get_initial_section(self._progress_bar_sections) def __enter__(self) -> 'CompositeProgressBar': self._progress_bar = self._progress_bar_context_manager.__enter__() @@ -140,7 +183,7 @@ def stop(self) -> None: if self._run: self.__exit__(None, None, None) - def set_section_length(self, section: 'ProgressBarSection', length: int) -> None: + def set_section_length(self, section: 'ProgressBarSection', length: int = 0) -> None: logger.debug(f'set_section_length: {section} {length}') self._section_lengths[section] = length @@ -149,8 +192,12 @@ def set_section_length(self, section: 'ProgressBarSection', length: int) -> None else: self._maybe_update_current_section() + def _get_section_length(self, section: 'ProgressBarSection') -> int: + section_info = self._progress_bar_sections[section] + return section_info.stop_percent - section_info.start_percent + def _skip_section(self, section: 'ProgressBarSection') -> None: - self._progress_bar.update(_get_section_length(section)) + self._progress_bar.update(self._get_section_length(section)) self._maybe_update_current_section() def _increment_section_value(self, section: 'ProgressBarSection', value: int) -> None: @@ -164,7 +211,7 @@ def _rerender_progress_bar(self) -> None: """Used to update label right after changing the progress bar section.""" self._progress_bar.update(0) - def _increment_progress(self, section: ProgressBarSection) -> None: + def _increment_progress(self, section: 'ProgressBarSection') -> None: increment_value = self._get_increment_progress_value(section) self._current_section_value += increment_value @@ -177,7 +224,7 @@ def _maybe_update_current_section(self) -> None: max_val = self._section_lengths.get(self._current_section.section, 0) cur_val = self._section_values.get(self._current_section.section, 0) if cur_val >= max_val: - next_section = _PROGRESS_BAR_SECTIONS[self._current_section.section.next()] + next_section = self._progress_bar_sections[self._current_section.section.next()] logger.debug(f'_update_current_section: {self._current_section.section} -> {next_section.section}') self._current_section = next_section @@ -188,7 +235,7 @@ def _get_increment_progress_value(self, section: 'ProgressBarSection') -> int: max_val = self._section_lengths[section] cur_val = self._section_values[section] - expected_value = round(_get_section_length(section) * (cur_val / max_val)) + expected_value = round(self._get_section_length(section) * (cur_val / max_val)) return expected_value - self._current_section_value @@ -210,12 +257,19 @@ def update(self, section: 'ProgressBarSection', value: int = 1) -> None: self._increment_progress(section) self._maybe_update_current_section() + def update_label(self, label: Optional[str] = None) -> None: + if not self._progress_bar: + raise ValueError('Progress bar is not initialized. Call start() first or use "with" statement.') + + self._progress_bar.label = label or '' + self._progress_bar.render_progress() -def get_progress_bar(*, hidden: bool) -> BaseProgressBar: + +def get_progress_bar(*, hidden: bool, sections: ProgressBarSections) -> BaseProgressBar: if hidden: return DummyProgressBar() - return CompositeProgressBar() + return CompositeProgressBar(sections) if __name__ == '__main__': @@ -223,15 +277,18 @@ def get_progress_bar(*, hidden: bool) -> BaseProgressBar: import random import time - bar = get_progress_bar(hidden=False) + bar = get_progress_bar(hidden=False, sections=SCAN_PROGRESS_BAR_SECTIONS) bar.start() - for bar_section in ProgressBarSection: + for bar_section in ScanProgressBarSection: section_capacity = random.randint(500, 1000) # noqa: S311 bar.set_section_length(bar_section, section_capacity) for _i in range(section_capacity): time.sleep(0.01) + bar.update_label(f'{bar_section} {_i}/{section_capacity}') bar.update(bar_section) + bar.update_label() + bar.stop() diff --git a/cycode/cli/utils/scan_batch.py b/cycode/cli/utils/scan_batch.py index 4c839440..ede229e2 100644 --- a/cycode/cli/utils/scan_batch.py +++ b/cycode/cli/utils/scan_batch.py @@ -9,7 +9,7 @@ SCAN_BATCH_SCANS_PER_CPU, ) from cycode.cli.models import Document -from cycode.cli.utils.progress_bar import ProgressBarSection +from cycode.cli.utils.progress_bar import ScanProgressBarSection if TYPE_CHECKING: from cycode.cli.models import CliError, LocalScanResult @@ -56,7 +56,7 @@ def run_parallel_batched_scan( max_files_count: int = SCAN_BATCH_MAX_FILES_COUNT, ) -> Tuple[Dict[str, 'CliError'], List['LocalScanResult']]: batches = split_documents_into_batches(documents, max_size_mb, max_files_count) - progress_bar.set_section_length(ProgressBarSection.SCAN, len(batches)) # * 3 + progress_bar.set_section_length(ScanProgressBarSection.SCAN, len(batches)) # * 3 # TODO(MarshalX): we should multiply the count of batches in SCAN section because each batch has 3 steps: # 1. scan creation # 2. scan completion @@ -73,6 +73,6 @@ def run_parallel_batched_scan( if err: cli_errors[scan_id] = err - progress_bar.update(ProgressBarSection.SCAN) + progress_bar.update(ScanProgressBarSection.SCAN) return cli_errors, local_scan_results diff --git a/cycode/cyclient/client_creator.py b/cycode/cyclient/client_creator.py new file mode 100644 index 00000000..da62bd5a --- /dev/null +++ b/cycode/cyclient/client_creator.py @@ -0,0 +1,23 @@ +from cycode.cyclient.config import dev_mode +from cycode.cyclient.config_dev import DEV_CYCODE_API_URL +from cycode.cyclient.cycode_dev_based_client import CycodeDevBasedClient +from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient +from cycode.cyclient.report_client import ReportClient +from cycode.cyclient.scan_client import ScanClient +from cycode.cyclient.scan_config_base import DefaultScanConfig, DevScanConfig + + +def create_scan_client(client_id: str, client_secret: str, hide_response_log: bool) -> ScanClient: + if dev_mode: + client = CycodeDevBasedClient(DEV_CYCODE_API_URL) + scan_config = DevScanConfig() + else: + client = CycodeTokenBasedClient(client_id, client_secret) + scan_config = DefaultScanConfig() + + return ScanClient(client, scan_config, hide_response_log) + + +def create_report_client(client_id: str, client_secret: str, hide_response_log: bool) -> ReportClient: + client = CycodeDevBasedClient(DEV_CYCODE_API_URL) if dev_mode else CycodeTokenBasedClient(client_id, client_secret) + return ReportClient(client, hide_response_log) diff --git a/cycode/cyclient/models.py b/cycode/cyclient/models.py index f5983083..0401e3fb 100644 --- a/cycode/cyclient/models.py +++ b/cycode/cyclient/models.py @@ -344,3 +344,63 @@ def user_agent_suffix(self) -> str: f'EnvName: {self.env_name}; EnvVersion: {self.env_version}' f')' ) + + +@dataclass +class SbomReportStorageDetails: + path: str + folder: str + size: int + + +class SbomReportStorageDetailsSchema(Schema): + class Meta: + unknown = EXCLUDE + + path = fields.String() + folder = fields.String() + size = fields.Integer() + + @post_load + def build_dto(self, data: Dict[str, Any], **_) -> SbomReportStorageDetails: + return SbomReportStorageDetails(**data) + + +@dataclass +class ReportExecution: + id: int + status: str + error_message: Optional[str] = None + status_message: Optional[str] = None + storage_details: Optional[SbomReportStorageDetails] = None + + +class ReportExecutionSchema(Schema): + class Meta: + unknown = EXCLUDE + + id = fields.Integer() + status = fields.String() + error_message = fields.String(allow_none=True) + status_message = fields.String(allow_none=True) + storage_details = fields.Nested(SbomReportStorageDetailsSchema, allow_none=True) + + @post_load + def build_dto(self, data: Dict[str, Any], **_) -> ReportExecution: + return ReportExecution(**data) + + +@dataclass +class SbomReport: + report_executions: List[ReportExecution] + + +class RequestedSbomReportResultSchema(Schema): + class Meta: + unknown = EXCLUDE + + report_executions = fields.List(fields.Nested(ReportExecutionSchema)) + + @post_load + def build_dto(self, data: Dict[str, Any], **_) -> SbomReport: + return SbomReport(**data) diff --git a/cycode/cyclient/report_client.py b/cycode/cyclient/report_client.py new file mode 100644 index 00000000..ade7d850 --- /dev/null +++ b/cycode/cyclient/report_client.py @@ -0,0 +1,101 @@ +import dataclasses +import json +from typing import List, Optional + +from requests import Response + +from cycode.cli.exceptions.custom_exceptions import CycodeError +from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip +from cycode.cyclient import models +from cycode.cyclient.cycode_client_base import CycodeClientBase + + +@dataclasses.dataclass +class ReportParameters: + entity_type: str + sbom_report_type: str + sbom_version: str + output_format: str + include_vulnerabilities: bool + include_dev_dependencies: bool + + def to_dict(self, *, without_entity_type: bool) -> dict: + model_dict = dataclasses.asdict(self) + if without_entity_type: + del model_dict['entity_type'] + return model_dict + + def to_json(self, *, without_entity_type: bool) -> str: + return json.dumps(self.to_dict(without_entity_type=without_entity_type)) + + +class ReportClient: + SERVICE_NAME: str = 'report' + CREATE_SBOM_REPORT_REQUEST_PATH: str = 'api/v2/report/{report_type}/sbom' + GET_EXECUTIONS_STATUS_PATH: str = 'api/v2/report/executions' + REPORT_STATUS_PATH: str = 'api/v2/report/{report_execution_id}/status' + + DOWNLOAD_REPORT_PATH: str = 'files/api/v1/file/sbom/{file_name}' # not in the report service + + def __init__(self, client: CycodeClientBase, hide_response_log: bool = True) -> None: + self.client = client + self._hide_response_log = hide_response_log + + def request_sbom_report_execution( + self, params: ReportParameters, zip_file: InMemoryZip = None, repository_url: Optional[str] = None + ) -> models.ReportExecution: + report_type = 'zipped-file' if zip_file else 'repository-url' + url_path = f'{self.SERVICE_NAME}/{self.CREATE_SBOM_REPORT_REQUEST_PATH}'.format(report_type=report_type) + + # entity type required only for zipped-file + request_data = {'report_parameters': params.to_json(without_entity_type=zip_file is None)} + if repository_url: + request_data['repository_url'] = repository_url + + request_args = { + 'url_path': url_path, + 'data': request_data, + 'hide_response_content_log': self._hide_response_log, + } + + if zip_file: + request_args['files'] = {'file': ('sca_files.zip', zip_file.read())} + + response = self.client.post(**request_args) + sbom_report = self.parse_requested_sbom_report_response(response) + if not sbom_report.report_executions: + raise CycodeError('Failed to get SBOM report. No executions found.') + + return sbom_report.report_executions[0] + + def get_report_execution(self, report_execution_id: int) -> models.ReportExecutionSchema: + url_path = f'{self.SERVICE_NAME}/{self.GET_EXECUTIONS_STATUS_PATH}' + params = { + 'executions_ids': report_execution_id, + 'include_orphan_executions': True, + } + response = self.client.get(url_path=url_path, params=params) + + report_executions = self.parse_execution_status_response(response) + if not report_executions: + raise CycodeError('Failed to get report execution.') + + return report_executions[0] + + def get_file_content(self, file_name: str) -> str: + response = self.client.get( + url_path=self.DOWNLOAD_REPORT_PATH.format(file_name=file_name), params={'include_hidden': True} + ) + return response.text + + def report_status(self, report_execution_id: int, status: dict) -> None: + url_path = f'{self.SERVICE_NAME}/{self.REPORT_STATUS_PATH}'.format(report_execution_id=report_execution_id) + self.client.post(url_path=url_path, body=status) + + @staticmethod + def parse_requested_sbom_report_response(response: Response) -> models.SbomReport: + return models.RequestedSbomReportResultSchema().load(response.json()) + + @staticmethod + def parse_execution_status_response(response: Response) -> List[models.ReportExecutionSchema]: + return models.ReportExecutionSchema().load(response.json(), many=True) diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index f09a96ef..5830e9dc 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -1,18 +1,19 @@ import json -from typing import List, Optional +from typing import TYPE_CHECKING, List, Optional from requests import Response -from cycode.cli.zip_file import InMemoryZip +from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip +from cycode.cyclient import models +from cycode.cyclient.cycode_client_base import CycodeClientBase -from . import models -from .cycode_client_base import CycodeClientBase -from .scan_config.scan_config_base import ScanConfigBase +if TYPE_CHECKING: + from .scan_config_base import ScanConfigBase class ScanClient: def __init__( - self, scan_cycode_client: CycodeClientBase, scan_config: ScanConfigBase, hide_response_log: bool = True + self, scan_cycode_client: CycodeClientBase, scan_config: 'ScanConfigBase', hide_response_log: bool = True ) -> None: self.scan_cycode_client = scan_cycode_client self.scan_config = scan_config diff --git a/cycode/cyclient/scan_config/scan_config_creator.py b/cycode/cyclient/scan_config/scan_config_creator.py deleted file mode 100644 index f17be424..00000000 --- a/cycode/cyclient/scan_config/scan_config_creator.py +++ /dev/null @@ -1,29 +0,0 @@ -from typing import Tuple - -from cycode.cyclient.config import dev_mode -from cycode.cyclient.config_dev import DEV_CYCODE_API_URL -from cycode.cyclient.cycode_dev_based_client import CycodeDevBasedClient -from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient -from cycode.cyclient.scan_client import ScanClient -from cycode.cyclient.scan_config.scan_config_base import DefaultScanConfig, DevScanConfig - - -def create_scan_client(client_id: str, client_secret: str, hide_response_log: bool) -> ScanClient: - if dev_mode: - scan_cycode_client, scan_config = create_scan_for_dev_env() - else: - scan_cycode_client, scan_config = create_scan(client_id, client_secret) - - return ScanClient(scan_cycode_client, scan_config, hide_response_log) - - -def create_scan(client_id: str, client_secret: str) -> Tuple[CycodeTokenBasedClient, DefaultScanConfig]: - scan_cycode_client = CycodeTokenBasedClient(client_id, client_secret) - scan_config = DefaultScanConfig() - return scan_cycode_client, scan_config - - -def create_scan_for_dev_env() -> Tuple[CycodeDevBasedClient, DevScanConfig]: - scan_cycode_client = CycodeDevBasedClient(DEV_CYCODE_API_URL) - scan_config = DevScanConfig() - return scan_cycode_client, scan_config diff --git a/cycode/cyclient/scan_config/scan_config_base.py b/cycode/cyclient/scan_config_base.py similarity index 100% rename from cycode/cyclient/scan_config/scan_config_base.py rename to cycode/cyclient/scan_config_base.py diff --git a/tests/cli/helpers/test_tf_content_generator.py b/tests/cli/helpers/test_tf_content_generator.py index ae19b2f6..7953ed81 100644 --- a/tests/cli/helpers/test_tf_content_generator.py +++ b/tests/cli/helpers/test_tf_content_generator.py @@ -1,6 +1,6 @@ import os -from cycode.cli.helpers import tf_content_generator +from cycode.cli.files_collector.iac import tf_content_generator from cycode.cli.utils.path_utils import get_file_content, get_immediate_subdirectories from tests.conftest import TEST_FILES_PATH diff --git a/tests/cli/test_code_scanner.py b/tests/cli/test_code_scanner.py index b715e9c6..f4fe4f69 100644 --- a/tests/cli/test_code_scanner.py +++ b/tests/cli/test_code_scanner.py @@ -8,8 +8,10 @@ from requests import Response from cycode.cli import consts -from cycode.cli.code_scanner import _generate_document, _handle_exception, _is_file_relevant_for_sca_scan +from cycode.cli.code_scanner import _handle_exception from cycode.cli.exceptions import custom_exceptions +from cycode.cli.files_collector.excluder import _is_file_relevant_for_sca_scan +from cycode.cli.files_collector.path_documents import _generate_document from cycode.cli.models import Document if TYPE_CHECKING: diff --git a/tests/conftest.py b/tests/conftest.py index a763f6bb..fdb02ec2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,9 +3,9 @@ import pytest import responses +from cycode.cyclient.client_creator import create_scan_client from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient from cycode.cyclient.scan_client import ScanClient -from cycode.cyclient.scan_config.scan_config_creator import create_scan_client _EXPECTED_API_TOKEN = 'someJWT' diff --git a/tests/cyclient/scan_config/test_default_scan_config.py b/tests/cyclient/scan_config/test_default_scan_config.py index 0945402b..e0a84ad2 100644 --- a/tests/cyclient/scan_config/test_default_scan_config.py +++ b/tests/cyclient/scan_config/test_default_scan_config.py @@ -1,4 +1,4 @@ -from cycode.cyclient.scan_config.scan_config_creator import DefaultScanConfig +from cycode.cyclient.scan_config_base import DefaultScanConfig def test_get_service_name() -> None: diff --git a/tests/cyclient/scan_config/test_dev_scan_config.py b/tests/cyclient/scan_config/test_dev_scan_config.py index 0673d601..3ea3127e 100644 --- a/tests/cyclient/scan_config/test_dev_scan_config.py +++ b/tests/cyclient/scan_config/test_dev_scan_config.py @@ -1,4 +1,4 @@ -from cycode.cyclient.scan_config.scan_config_creator import DevScanConfig +from cycode.cyclient.scan_config_base import DevScanConfig def test_get_service_name() -> None: diff --git a/tests/cyclient/test_scan_client.py b/tests/cyclient/test_scan_client.py index db867a9f..2ca374b2 100644 --- a/tests/cyclient/test_scan_client.py +++ b/tests/cyclient/test_scan_client.py @@ -8,11 +8,11 @@ from requests import Timeout from requests.exceptions import ProxyError -from cycode.cli.code_scanner import zip_documents_to_scan +from cycode.cli.code_scanner import zip_documents from cycode.cli.config import config from cycode.cli.exceptions.custom_exceptions import CycodeError, HttpUnauthorizedError +from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip from cycode.cli.models import Document -from cycode.cli.zip_file import InMemoryZip from cycode.cyclient.scan_client import ScanClient from tests.conftest import TEST_FILES_PATH @@ -42,7 +42,7 @@ def get_test_zip_file(scan_type: str) -> InMemoryZip: with open(path, 'r', encoding='UTF-8') as f: test_documents.append(Document(path, f.read(), is_git_diff_format=False)) - return zip_documents_to_scan(scan_type, InMemoryZip(), test_documents) + return zip_documents(scan_type, test_documents) def get_zipped_file_scan_response(url: str, scan_id: Optional[UUID] = None) -> responses.Response: diff --git a/tests/test_code_scanner.py b/tests/test_code_scanner.py index b1f7e163..6eb494e9 100644 --- a/tests/test_code_scanner.py +++ b/tests/test_code_scanner.py @@ -1,9 +1,9 @@ import os -from cycode.cli import code_scanner +from cycode.cli.files_collector.excluder import _is_relevant_file_to_scan from tests.conftest import TEST_FILES_PATH def test_is_relevant_file_to_scan_sca() -> None: path = os.path.join(TEST_FILES_PATH, 'package.json') - assert code_scanner._is_relevant_file_to_scan('sca', path) is True + assert _is_relevant_file_to_scan('sca', path) is True diff --git a/tests/test_zip_file.py b/tests/test_zip_file.py index f73514c8..15c53c17 100644 --- a/tests/test_zip_file.py +++ b/tests/test_zip_file.py @@ -1,6 +1,6 @@ import os -from cycode.cli import zip_file +from cycode.cli.utils.path_utils import concat_unique_id def test_concat_unique_id_to_file_with_leading_slash() -> None: @@ -10,7 +10,7 @@ def test_concat_unique_id_to_file_with_leading_slash() -> None: expected_path = os.path.join(unique_id, filename) filename = os.sep + filename - assert zip_file.concat_unique_id(filename, unique_id) == expected_path + assert concat_unique_id(filename, unique_id) == expected_path def test_concat_unique_id_to_file_without_leading_slash() -> None: @@ -19,4 +19,4 @@ def test_concat_unique_id_to_file_without_leading_slash() -> None: expected_path = os.path.join(unique_id, *filename.split('/')) - assert zip_file.concat_unique_id(filename, unique_id) == expected_path + assert concat_unique_id(filename, unique_id) == expected_path From 5e10e073551ba4be20b844c94905027d83da3e05 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Mon, 9 Oct 2023 19:31:01 +0200 Subject: [PATCH 034/257] CM-27780 - Support Python 3.12 (#166) --- .github/workflows/tests_full.yml | 2 +- poetry.lock | 531 ++++++++++++++++--------------- pyproject.toml | 5 +- 3 files changed, 283 insertions(+), 255 deletions(-) diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index efd8226f..aa2c0fc2 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: os: [ macos-latest, ubuntu-latest, windows-latest ] - python-version: [ "3.7", "3.8", "3.9", "3.10", "3.11" ] + python-version: [ "3.7", "3.8", "3.9", "3.10", "3.11", "3.12" ] runs-on: ${{matrix.os}} diff --git a/poetry.lock b/poetry.lock index 46104e63..a6a0126b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2,13 +2,13 @@ [[package]] name = "altgraph" -version = "0.17.3" +version = "0.17.4" description = "Python graph (network) package" optional = false python-versions = "*" files = [ - {file = "altgraph-0.17.3-py2.py3-none-any.whl", hash = "sha256:c8ac1ca6772207179ed8003ce7687757c04b0b71536f81e2ac5755c6226458fe"}, - {file = "altgraph-0.17.3.tar.gz", hash = "sha256:ad33358114df7c9416cdb8fa1eaa5852166c505118717021c6a8c7c7abbd03dd"}, + {file = "altgraph-0.17.4-py2.py3-none-any.whl", hash = "sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff"}, + {file = "altgraph-0.17.4.tar.gz", hash = "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406"}, ] [[package]] @@ -102,108 +102,123 @@ files = [ [[package]] name = "chardet" -version = "5.1.0" +version = "5.2.0" description = "Universal encoding detector for Python 3" optional = false python-versions = ">=3.7" files = [ - {file = "chardet-5.1.0-py3-none-any.whl", hash = "sha256:362777fb014af596ad31334fde1e8c327dfdb076e1960d1694662d46a6917ab9"}, - {file = "chardet-5.1.0.tar.gz", hash = "sha256:0d62712b956bc154f85fb0a266e2a3c5913c2967e00348701b32411d6def31e5"}, + {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, + {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, ] [[package]] name = "charset-normalizer" -version = "3.1.0" +version = "3.3.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7.0" files = [ - {file = "charset-normalizer-3.1.0.tar.gz", hash = "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-win32.whl", hash = "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-win32.whl", hash = "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0c95f12b74681e9ae127728f7e5409cbbef9cd914d5896ef238cc779b8152373"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac0aa6cd53ab9a31d397f8303f92c42f534693528fafbdb997c82bae6e477ad9"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3af8e0f07399d3176b179f2e2634c3ce9c1301379a6b8c9c9aeecd481da494f"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a5fc78f9e3f501a1614a98f7c54d3969f3ad9bba8ba3d9b438c3bc5d047dd28"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:628c985afb2c7d27a4800bfb609e03985aaecb42f955049957814e0491d4006d"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:74db0052d985cf37fa111828d0dd230776ac99c740e1a758ad99094be4f1803d"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1e8fcdd8f672a1c4fc8d0bd3a2b576b152d2a349782d1eb0f6b8e52e9954731d"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:04afa6387e2b282cf78ff3dbce20f0cc071c12dc8f685bd40960cc68644cfea6"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:dd5653e67b149503c68c4018bf07e42eeed6b4e956b24c00ccdf93ac79cdff84"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d2686f91611f9e17f4548dbf050e75b079bbc2a82be565832bc8ea9047b61c8c"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-win32.whl", hash = "sha256:4155b51ae05ed47199dc5b2a4e62abccb274cee6b01da5b895099b61b1982974"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:322102cdf1ab682ecc7d9b1c5eed4ec59657a65e1c146a0da342b78f4112db23"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e633940f28c1e913615fd624fcdd72fdba807bf53ea6925d6a588e84e1151531"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3a06f32c9634a8705f4ca9946d667609f52cf130d5548881401f1eb2c39b1e2c"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7381c66e0561c5757ffe616af869b916c8b4e42b367ab29fedc98481d1e74e14"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3573d376454d956553c356df45bb824262c397c6e26ce43e8203c4c540ee0acb"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e89df2958e5159b811af9ff0f92614dabf4ff617c03a4c1c6ff53bf1c399e0e1"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78cacd03e79d009d95635e7d6ff12c21eb89b894c354bd2b2ed0b4763373693b"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de5695a6f1d8340b12a5d6d4484290ee74d61e467c39ff03b39e30df62cf83a0"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c60b9c202d00052183c9be85e5eaf18a4ada0a47d188a83c8f5c5b23252f649"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f645caaf0008bacf349875a974220f1f1da349c5dbe7c4ec93048cdc785a3326"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ea9f9c6034ea2d93d9147818f17c2a0860d41b71c38b9ce4d55f21b6f9165a11"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:80d1543d58bd3d6c271b66abf454d437a438dff01c3e62fdbcd68f2a11310d4b"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:73dc03a6a7e30b7edc5b01b601e53e7fc924b04e1835e8e407c12c037e81adbd"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6f5c2e7bc8a4bf7c426599765b1bd33217ec84023033672c1e9a8b35eaeaaaf8"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-win32.whl", hash = "sha256:12a2b561af122e3d94cdb97fe6fb2bb2b82cef0cdca131646fdb940a1eda04f0"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3160a0fd9754aab7d47f95a6b63ab355388d890163eb03b2d2b87ab0a30cfa59"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-win32.whl", hash = "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b"}, - {file = "charset_normalizer-3.1.0-py3-none-any.whl", hash = "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d"}, + {file = "charset-normalizer-3.3.0.tar.gz", hash = "sha256:63563193aec44bce707e0c5ca64ff69fa72ed7cf34ce6e11d5127555756fd2f6"}, + {file = "charset_normalizer-3.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:effe5406c9bd748a871dbcaf3ac69167c38d72db8c9baf3ff954c344f31c4cbe"}, + {file = "charset_normalizer-3.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4162918ef3098851fcd8a628bf9b6a98d10c380725df9e04caf5ca6dd48c847a"}, + {file = "charset_normalizer-3.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0570d21da019941634a531444364f2482e8db0b3425fcd5ac0c36565a64142c8"}, + {file = "charset_normalizer-3.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5707a746c6083a3a74b46b3a631d78d129edab06195a92a8ece755aac25a3f3d"}, + {file = "charset_normalizer-3.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:278c296c6f96fa686d74eb449ea1697f3c03dc28b75f873b65b5201806346a69"}, + {file = "charset_normalizer-3.3.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a4b71f4d1765639372a3b32d2638197f5cd5221b19531f9245fcc9ee62d38f56"}, + {file = "charset_normalizer-3.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5969baeaea61c97efa706b9b107dcba02784b1601c74ac84f2a532ea079403e"}, + {file = "charset_normalizer-3.3.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a3f93dab657839dfa61025056606600a11d0b696d79386f974e459a3fbc568ec"}, + {file = "charset_normalizer-3.3.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:db756e48f9c5c607b5e33dd36b1d5872d0422e960145b08ab0ec7fd420e9d649"}, + {file = "charset_normalizer-3.3.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:232ac332403e37e4a03d209a3f92ed9071f7d3dbda70e2a5e9cff1c4ba9f0678"}, + {file = "charset_normalizer-3.3.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:e5c1502d4ace69a179305abb3f0bb6141cbe4714bc9b31d427329a95acfc8bdd"}, + {file = "charset_normalizer-3.3.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:2502dd2a736c879c0f0d3e2161e74d9907231e25d35794584b1ca5284e43f596"}, + {file = "charset_normalizer-3.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23e8565ab7ff33218530bc817922fae827420f143479b753104ab801145b1d5b"}, + {file = "charset_normalizer-3.3.0-cp310-cp310-win32.whl", hash = "sha256:1872d01ac8c618a8da634e232f24793883d6e456a66593135aeafe3784b0848d"}, + {file = "charset_normalizer-3.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:557b21a44ceac6c6b9773bc65aa1b4cc3e248a5ad2f5b914b91579a32e22204d"}, + {file = "charset_normalizer-3.3.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d7eff0f27edc5afa9e405f7165f85a6d782d308f3b6b9d96016c010597958e63"}, + {file = "charset_normalizer-3.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a685067d05e46641d5d1623d7c7fdf15a357546cbb2f71b0ebde91b175ffc3e"}, + {file = "charset_normalizer-3.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0d3d5b7db9ed8a2b11a774db2bbea7ba1884430a205dbd54a32d61d7c2a190fa"}, + {file = "charset_normalizer-3.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2935ffc78db9645cb2086c2f8f4cfd23d9b73cc0dc80334bc30aac6f03f68f8c"}, + {file = "charset_normalizer-3.3.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fe359b2e3a7729010060fbca442ca225280c16e923b37db0e955ac2a2b72a05"}, + {file = "charset_normalizer-3.3.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380c4bde80bce25c6e4f77b19386f5ec9db230df9f2f2ac1e5ad7af2caa70459"}, + {file = "charset_normalizer-3.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0d1e3732768fecb052d90d62b220af62ead5748ac51ef61e7b32c266cac9293"}, + {file = "charset_normalizer-3.3.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1b2919306936ac6efb3aed1fbf81039f7087ddadb3160882a57ee2ff74fd2382"}, + {file = "charset_normalizer-3.3.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f8888e31e3a85943743f8fc15e71536bda1c81d5aa36d014a3c0c44481d7db6e"}, + {file = "charset_normalizer-3.3.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:82eb849f085624f6a607538ee7b83a6d8126df6d2f7d3b319cb837b289123078"}, + {file = "charset_normalizer-3.3.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:7b8b8bf1189b3ba9b8de5c8db4d541b406611a71a955bbbd7385bbc45fcb786c"}, + {file = "charset_normalizer-3.3.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:5adf257bd58c1b8632046bbe43ee38c04e1038e9d37de9c57a94d6bd6ce5da34"}, + {file = "charset_normalizer-3.3.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c350354efb159b8767a6244c166f66e67506e06c8924ed74669b2c70bc8735b1"}, + {file = "charset_normalizer-3.3.0-cp311-cp311-win32.whl", hash = "sha256:02af06682e3590ab952599fbadac535ede5d60d78848e555aa58d0c0abbde786"}, + {file = "charset_normalizer-3.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:86d1f65ac145e2c9ed71d8ffb1905e9bba3a91ae29ba55b4c46ae6fc31d7c0d4"}, + {file = "charset_normalizer-3.3.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:3b447982ad46348c02cb90d230b75ac34e9886273df3a93eec0539308a6296d7"}, + {file = "charset_normalizer-3.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:abf0d9f45ea5fb95051c8bfe43cb40cda383772f7e5023a83cc481ca2604d74e"}, + {file = "charset_normalizer-3.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b09719a17a2301178fac4470d54b1680b18a5048b481cb8890e1ef820cb80455"}, + {file = "charset_normalizer-3.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3d9b48ee6e3967b7901c052b670c7dda6deb812c309439adaffdec55c6d7b78"}, + {file = "charset_normalizer-3.3.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:edfe077ab09442d4ef3c52cb1f9dab89bff02f4524afc0acf2d46be17dc479f5"}, + {file = "charset_normalizer-3.3.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3debd1150027933210c2fc321527c2299118aa929c2f5a0a80ab6953e3bd1908"}, + {file = "charset_normalizer-3.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86f63face3a527284f7bb8a9d4f78988e3c06823f7bea2bd6f0e0e9298ca0403"}, + {file = "charset_normalizer-3.3.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:24817cb02cbef7cd499f7c9a2735286b4782bd47a5b3516a0e84c50eab44b98e"}, + {file = "charset_normalizer-3.3.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c71f16da1ed8949774ef79f4a0260d28b83b3a50c6576f8f4f0288d109777989"}, + {file = "charset_normalizer-3.3.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:9cf3126b85822c4e53aa28c7ec9869b924d6fcfb76e77a45c44b83d91afd74f9"}, + {file = "charset_normalizer-3.3.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:b3b2316b25644b23b54a6f6401074cebcecd1244c0b8e80111c9a3f1c8e83d65"}, + {file = "charset_normalizer-3.3.0-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:03680bb39035fbcffe828eae9c3f8afc0428c91d38e7d61aa992ef7a59fb120e"}, + {file = "charset_normalizer-3.3.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4cc152c5dd831641e995764f9f0b6589519f6f5123258ccaca8c6d34572fefa8"}, + {file = "charset_normalizer-3.3.0-cp312-cp312-win32.whl", hash = "sha256:b8f3307af845803fb0b060ab76cf6dd3a13adc15b6b451f54281d25911eb92df"}, + {file = "charset_normalizer-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:8eaf82f0eccd1505cf39a45a6bd0a8cf1c70dcfc30dba338207a969d91b965c0"}, + {file = "charset_normalizer-3.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dc45229747b67ffc441b3de2f3ae5e62877a282ea828a5bdb67883c4ee4a8810"}, + {file = "charset_normalizer-3.3.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f4a0033ce9a76e391542c182f0d48d084855b5fcba5010f707c8e8c34663d77"}, + {file = "charset_normalizer-3.3.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ada214c6fa40f8d800e575de6b91a40d0548139e5dc457d2ebb61470abf50186"}, + {file = "charset_normalizer-3.3.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b1121de0e9d6e6ca08289583d7491e7fcb18a439305b34a30b20d8215922d43c"}, + {file = "charset_normalizer-3.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1063da2c85b95f2d1a430f1c33b55c9c17ffaf5e612e10aeaad641c55a9e2b9d"}, + {file = "charset_normalizer-3.3.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70f1d09c0d7748b73290b29219e854b3207aea922f839437870d8cc2168e31cc"}, + {file = "charset_normalizer-3.3.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:250c9eb0f4600361dd80d46112213dff2286231d92d3e52af1e5a6083d10cad9"}, + {file = "charset_normalizer-3.3.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:750b446b2ffce1739e8578576092179160f6d26bd5e23eb1789c4d64d5af7dc7"}, + {file = "charset_normalizer-3.3.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:fc52b79d83a3fe3a360902d3f5d79073a993597d48114c29485e9431092905d8"}, + {file = "charset_normalizer-3.3.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:588245972aca710b5b68802c8cad9edaa98589b1b42ad2b53accd6910dad3545"}, + {file = "charset_normalizer-3.3.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e39c7eb31e3f5b1f88caff88bcff1b7f8334975b46f6ac6e9fc725d829bc35d4"}, + {file = "charset_normalizer-3.3.0-cp37-cp37m-win32.whl", hash = "sha256:abecce40dfebbfa6abf8e324e1860092eeca6f7375c8c4e655a8afb61af58f2c"}, + {file = "charset_normalizer-3.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:24a91a981f185721542a0b7c92e9054b7ab4fea0508a795846bc5b0abf8118d4"}, + {file = "charset_normalizer-3.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:67b8cc9574bb518ec76dc8e705d4c39ae78bb96237cb533edac149352c1f39fe"}, + {file = "charset_normalizer-3.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ac71b2977fb90c35d41c9453116e283fac47bb9096ad917b8819ca8b943abecd"}, + {file = "charset_normalizer-3.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3ae38d325b512f63f8da31f826e6cb6c367336f95e418137286ba362925c877e"}, + {file = "charset_normalizer-3.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:542da1178c1c6af8873e143910e2269add130a299c9106eef2594e15dae5e482"}, + {file = "charset_normalizer-3.3.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30a85aed0b864ac88309b7d94be09f6046c834ef60762a8833b660139cfbad13"}, + {file = "charset_normalizer-3.3.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aae32c93e0f64469f74ccc730a7cb21c7610af3a775157e50bbd38f816536b38"}, + {file = "charset_normalizer-3.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15b26ddf78d57f1d143bdf32e820fd8935d36abe8a25eb9ec0b5a71c82eb3895"}, + {file = "charset_normalizer-3.3.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f5d10bae5d78e4551b7be7a9b29643a95aded9d0f602aa2ba584f0388e7a557"}, + {file = "charset_normalizer-3.3.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:249c6470a2b60935bafd1d1d13cd613f8cd8388d53461c67397ee6a0f5dce741"}, + {file = "charset_normalizer-3.3.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:c5a74c359b2d47d26cdbbc7845e9662d6b08a1e915eb015d044729e92e7050b7"}, + {file = "charset_normalizer-3.3.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:b5bcf60a228acae568e9911f410f9d9e0d43197d030ae5799e20dca8df588287"}, + {file = "charset_normalizer-3.3.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:187d18082694a29005ba2944c882344b6748d5be69e3a89bf3cc9d878e548d5a"}, + {file = "charset_normalizer-3.3.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:81bf654678e575403736b85ba3a7867e31c2c30a69bc57fe88e3ace52fb17b89"}, + {file = "charset_normalizer-3.3.0-cp38-cp38-win32.whl", hash = "sha256:85a32721ddde63c9df9ebb0d2045b9691d9750cb139c161c80e500d210f5e26e"}, + {file = "charset_normalizer-3.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:468d2a840567b13a590e67dd276c570f8de00ed767ecc611994c301d0f8c014f"}, + {file = "charset_normalizer-3.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e0fc42822278451bc13a2e8626cf2218ba570f27856b536e00cfa53099724828"}, + {file = "charset_normalizer-3.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:09c77f964f351a7369cc343911e0df63e762e42bac24cd7d18525961c81754f4"}, + {file = "charset_normalizer-3.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:12ebea541c44fdc88ccb794a13fe861cc5e35d64ed689513a5c03d05b53b7c82"}, + {file = "charset_normalizer-3.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:805dfea4ca10411a5296bcc75638017215a93ffb584c9e344731eef0dcfb026a"}, + {file = "charset_normalizer-3.3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:96c2b49eb6a72c0e4991d62406e365d87067ca14c1a729a870d22354e6f68115"}, + {file = "charset_normalizer-3.3.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aaf7b34c5bc56b38c931a54f7952f1ff0ae77a2e82496583b247f7c969eb1479"}, + {file = "charset_normalizer-3.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:619d1c96099be5823db34fe89e2582b336b5b074a7f47f819d6b3a57ff7bdb86"}, + {file = "charset_normalizer-3.3.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a0ac5e7015a5920cfce654c06618ec40c33e12801711da6b4258af59a8eff00a"}, + {file = "charset_normalizer-3.3.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:93aa7eef6ee71c629b51ef873991d6911b906d7312c6e8e99790c0f33c576f89"}, + {file = "charset_normalizer-3.3.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7966951325782121e67c81299a031f4c115615e68046f79b85856b86ebffc4cd"}, + {file = "charset_normalizer-3.3.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:02673e456dc5ab13659f85196c534dc596d4ef260e4d86e856c3b2773ce09843"}, + {file = "charset_normalizer-3.3.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:c2af80fb58f0f24b3f3adcb9148e6203fa67dd3f61c4af146ecad033024dde43"}, + {file = "charset_normalizer-3.3.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:153e7b6e724761741e0974fc4dcd406d35ba70b92bfe3fedcb497226c93b9da7"}, + {file = "charset_normalizer-3.3.0-cp39-cp39-win32.whl", hash = "sha256:d47ecf253780c90ee181d4d871cd655a789da937454045b17b5798da9393901a"}, + {file = "charset_normalizer-3.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:d97d85fa63f315a8bdaba2af9a6a686e0eceab77b3089af45133252618e70884"}, + {file = "charset_normalizer-3.3.0-py3-none-any.whl", hash = "sha256:e46cd37076971c1040fc8c41273a8b3e2c624ce4f2be3f5dfcb7a430c1d3acc2"}, ] [[package]] name = "click" -version = "8.1.3" +version = "8.1.7" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" files = [ - {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, - {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, ] [package.dependencies] @@ -223,62 +238,71 @@ files = [ [[package]] name = "coverage" -version = "7.2.5" +version = "7.2.7" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.7" files = [ - {file = "coverage-7.2.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:883123d0bbe1c136f76b56276074b0c79b5817dd4238097ffa64ac67257f4b6c"}, - {file = "coverage-7.2.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d2fbc2a127e857d2f8898aaabcc34c37771bf78a4d5e17d3e1f5c30cd0cbc62a"}, - {file = "coverage-7.2.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f3671662dc4b422b15776cdca89c041a6349b4864a43aa2350b6b0b03bbcc7f"}, - {file = "coverage-7.2.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:780551e47d62095e088f251f5db428473c26db7829884323e56d9c0c3118791a"}, - {file = "coverage-7.2.5-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:066b44897c493e0dcbc9e6a6d9f8bbb6607ef82367cf6810d387c09f0cd4fe9a"}, - {file = "coverage-7.2.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b9a4ee55174b04f6af539218f9f8083140f61a46eabcaa4234f3c2a452c4ed11"}, - {file = "coverage-7.2.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:706ec567267c96717ab9363904d846ec009a48d5f832140b6ad08aad3791b1f5"}, - {file = "coverage-7.2.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ae453f655640157d76209f42c62c64c4d4f2c7f97256d3567e3b439bd5c9b06c"}, - {file = "coverage-7.2.5-cp310-cp310-win32.whl", hash = "sha256:f81c9b4bd8aa747d417407a7f6f0b1469a43b36a85748145e144ac4e8d303cb5"}, - {file = "coverage-7.2.5-cp310-cp310-win_amd64.whl", hash = "sha256:dc945064a8783b86fcce9a0a705abd7db2117d95e340df8a4333f00be5efb64c"}, - {file = "coverage-7.2.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:40cc0f91c6cde033da493227797be2826cbf8f388eaa36a0271a97a332bfd7ce"}, - {file = "coverage-7.2.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a66e055254a26c82aead7ff420d9fa8dc2da10c82679ea850d8feebf11074d88"}, - {file = "coverage-7.2.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c10fbc8a64aa0f3ed136b0b086b6b577bc64d67d5581acd7cc129af52654384e"}, - {file = "coverage-7.2.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a22cbb5ede6fade0482111fa7f01115ff04039795d7092ed0db43522431b4f2"}, - {file = "coverage-7.2.5-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292300f76440651529b8ceec283a9370532f4ecba9ad67d120617021bb5ef139"}, - {file = "coverage-7.2.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7ff8f3fb38233035028dbc93715551d81eadc110199e14bbbfa01c5c4a43f8d8"}, - {file = "coverage-7.2.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:a08c7401d0b24e8c2982f4e307124b671c6736d40d1c39e09d7a8687bddf83ed"}, - {file = "coverage-7.2.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ef9659d1cda9ce9ac9585c045aaa1e59223b143f2407db0eaee0b61a4f266fb6"}, - {file = "coverage-7.2.5-cp311-cp311-win32.whl", hash = "sha256:30dcaf05adfa69c2a7b9f7dfd9f60bc8e36b282d7ed25c308ef9e114de7fc23b"}, - {file = "coverage-7.2.5-cp311-cp311-win_amd64.whl", hash = "sha256:97072cc90f1009386c8a5b7de9d4fc1a9f91ba5ef2146c55c1f005e7b5c5e068"}, - {file = "coverage-7.2.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:bebea5f5ed41f618797ce3ffb4606c64a5de92e9c3f26d26c2e0aae292f015c1"}, - {file = "coverage-7.2.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:828189fcdda99aae0d6bf718ea766b2e715eabc1868670a0a07bf8404bf58c33"}, - {file = "coverage-7.2.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e8a95f243d01ba572341c52f89f3acb98a3b6d1d5d830efba86033dd3687ade"}, - {file = "coverage-7.2.5-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8834e5f17d89e05697c3c043d3e58a8b19682bf365048837383abfe39adaed5"}, - {file = "coverage-7.2.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d1f25ee9de21a39b3a8516f2c5feb8de248f17da7eead089c2e04aa097936b47"}, - {file = "coverage-7.2.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1637253b11a18f453e34013c665d8bf15904c9e3c44fbda34c643fbdc9d452cd"}, - {file = "coverage-7.2.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8e575a59315a91ccd00c7757127f6b2488c2f914096077c745c2f1ba5b8c0969"}, - {file = "coverage-7.2.5-cp37-cp37m-win32.whl", hash = "sha256:509ecd8334c380000d259dc66feb191dd0a93b21f2453faa75f7f9cdcefc0718"}, - {file = "coverage-7.2.5-cp37-cp37m-win_amd64.whl", hash = "sha256:12580845917b1e59f8a1c2ffa6af6d0908cb39220f3019e36c110c943dc875b0"}, - {file = "coverage-7.2.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b5016e331b75310610c2cf955d9f58a9749943ed5f7b8cfc0bb89c6134ab0a84"}, - {file = "coverage-7.2.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:373ea34dca98f2fdb3e5cb33d83b6d801007a8074f992b80311fc589d3e6b790"}, - {file = "coverage-7.2.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a063aad9f7b4c9f9da7b2550eae0a582ffc7623dca1c925e50c3fbde7a579771"}, - {file = "coverage-7.2.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:38c0a497a000d50491055805313ed83ddba069353d102ece8aef5d11b5faf045"}, - {file = "coverage-7.2.5-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2b3b05e22a77bb0ae1a3125126a4e08535961c946b62f30985535ed40e26614"}, - {file = "coverage-7.2.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0342a28617e63ad15d96dca0f7ae9479a37b7d8a295f749c14f3436ea59fdcb3"}, - {file = "coverage-7.2.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:cf97ed82ca986e5c637ea286ba2793c85325b30f869bf64d3009ccc1a31ae3fd"}, - {file = "coverage-7.2.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:c2c41c1b1866b670573657d584de413df701f482574bad7e28214a2362cb1fd1"}, - {file = "coverage-7.2.5-cp38-cp38-win32.whl", hash = "sha256:10b15394c13544fce02382360cab54e51a9e0fd1bd61ae9ce012c0d1e103c813"}, - {file = "coverage-7.2.5-cp38-cp38-win_amd64.whl", hash = "sha256:a0b273fe6dc655b110e8dc89b8ec7f1a778d78c9fd9b4bda7c384c8906072212"}, - {file = "coverage-7.2.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5c587f52c81211d4530fa6857884d37f514bcf9453bdeee0ff93eaaf906a5c1b"}, - {file = "coverage-7.2.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4436cc9ba5414c2c998eaedee5343f49c02ca93b21769c5fdfa4f9d799e84200"}, - {file = "coverage-7.2.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6599bf92f33ab041e36e06d25890afbdf12078aacfe1f1d08c713906e49a3fe5"}, - {file = "coverage-7.2.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:857abe2fa6a4973f8663e039ead8d22215d31db613ace76e4a98f52ec919068e"}, - {file = "coverage-7.2.5-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6f5cab2d7f0c12f8187a376cc6582c477d2df91d63f75341307fcdcb5d60303"}, - {file = "coverage-7.2.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:aa387bd7489f3e1787ff82068b295bcaafbf6f79c3dad3cbc82ef88ce3f48ad3"}, - {file = "coverage-7.2.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:156192e5fd3dbbcb11cd777cc469cf010a294f4c736a2b2c891c77618cb1379a"}, - {file = "coverage-7.2.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bd3b4b8175c1db502adf209d06136c000df4d245105c8839e9d0be71c94aefe1"}, - {file = "coverage-7.2.5-cp39-cp39-win32.whl", hash = "sha256:ddc5a54edb653e9e215f75de377354e2455376f416c4378e1d43b08ec50acc31"}, - {file = "coverage-7.2.5-cp39-cp39-win_amd64.whl", hash = "sha256:338aa9d9883aaaad53695cb14ccdeb36d4060485bb9388446330bef9c361c252"}, - {file = "coverage-7.2.5-pp37.pp38.pp39-none-any.whl", hash = "sha256:8877d9b437b35a85c18e3c6499b23674684bf690f5d96c1006a1ef61f9fdf0f3"}, - {file = "coverage-7.2.5.tar.gz", hash = "sha256:f99ef080288f09ffc687423b8d60978cf3a465d3f404a18d1a05474bd8575a47"}, + {file = "coverage-7.2.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8"}, + {file = "coverage-7.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb"}, + {file = "coverage-7.2.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6"}, + {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2"}, + {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063"}, + {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1"}, + {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353"}, + {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495"}, + {file = "coverage-7.2.7-cp310-cp310-win32.whl", hash = "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818"}, + {file = "coverage-7.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850"}, + {file = "coverage-7.2.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f"}, + {file = "coverage-7.2.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe"}, + {file = "coverage-7.2.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3"}, + {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f"}, + {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb"}, + {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833"}, + {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97"}, + {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a"}, + {file = "coverage-7.2.7-cp311-cp311-win32.whl", hash = "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a"}, + {file = "coverage-7.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562"}, + {file = "coverage-7.2.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4"}, + {file = "coverage-7.2.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4"}, + {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01"}, + {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6"}, + {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d"}, + {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de"}, + {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d"}, + {file = "coverage-7.2.7-cp312-cp312-win32.whl", hash = "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511"}, + {file = "coverage-7.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3"}, + {file = "coverage-7.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f"}, + {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb"}, + {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9"}, + {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd"}, + {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a"}, + {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959"}, + {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02"}, + {file = "coverage-7.2.7-cp37-cp37m-win32.whl", hash = "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f"}, + {file = "coverage-7.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0"}, + {file = "coverage-7.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5"}, + {file = "coverage-7.2.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5"}, + {file = "coverage-7.2.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9"}, + {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6"}, + {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e"}, + {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050"}, + {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5"}, + {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f"}, + {file = "coverage-7.2.7-cp38-cp38-win32.whl", hash = "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e"}, + {file = "coverage-7.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c"}, + {file = "coverage-7.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9"}, + {file = "coverage-7.2.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2"}, + {file = "coverage-7.2.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7"}, + {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e"}, + {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1"}, + {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9"}, + {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250"}, + {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2"}, + {file = "coverage-7.2.7-cp39-cp39-win32.whl", hash = "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb"}, + {file = "coverage-7.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27"}, + {file = "coverage-7.2.7-pp37.pp38.pp39-none-any.whl", hash = "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d"}, + {file = "coverage-7.2.7.tar.gz", hash = "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59"}, ] [package.extras] @@ -301,13 +325,13 @@ packaging = ">=20.9" [[package]] name = "exceptiongroup" -version = "1.1.1" +version = "1.1.3" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"}, - {file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"}, + {file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"}, + {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"}, ] [package.extras] @@ -329,19 +353,22 @@ smmap = ">=3.0.1,<6" [[package]] name = "gitpython" -version = "3.1.35" +version = "3.1.37" description = "GitPython is a Python library used to interact with Git repositories" optional = false python-versions = ">=3.7" files = [ - {file = "GitPython-3.1.35-py3-none-any.whl", hash = "sha256:c19b4292d7a1d3c0f653858db273ff8a6614100d1eb1528b014ec97286193c09"}, - {file = "GitPython-3.1.35.tar.gz", hash = "sha256:9cbefbd1789a5fe9bcf621bb34d3f441f3a90c8461d377f84eda73e721d9b06b"}, + {file = "GitPython-3.1.37-py3-none-any.whl", hash = "sha256:5f4c4187de49616d710a77e98ddf17b4782060a1788df441846bddefbb89ab33"}, + {file = "GitPython-3.1.37.tar.gz", hash = "sha256:f9b9ddc0761c125d5780eab2d64be4873fc6817c2899cbcb34b02344bdc7bc54"}, ] [package.dependencies] gitdb = ">=4.0.1,<5" typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\""} +[package.extras] +test = ["black", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mypy", "pre-commit", "pytest", "pytest-cov", "pytest-sugar"] + [[package]] name = "idna" version = "3.4" @@ -355,13 +382,13 @@ files = [ [[package]] name = "importlib-metadata" -version = "6.6.0" +version = "6.7.0" description = "Read metadata from Python packages" optional = false python-versions = ">=3.7" files = [ - {file = "importlib_metadata-6.6.0-py3-none-any.whl", hash = "sha256:43dd286a2cd8995d5eaef7fee2066340423b818ed3fd70adf0bad5f1fac53fed"}, - {file = "importlib_metadata-6.6.0.tar.gz", hash = "sha256:92501cdf9cc66ebd3e612f1b4f0c0765dfa42f0fa38ffb319b6bd84dd675d705"}, + {file = "importlib_metadata-6.7.0-py3-none-any.whl", hash = "sha256:cb52082e659e97afc5dac71e79de97d8681de3aa07ff18578330904a9d18e5b5"}, + {file = "importlib_metadata-6.7.0.tar.gz", hash = "sha256:1aaf550d4f73e5d6783e7acb77aec43d49da8017410afae93822cc9cca98c4d4"}, ] [package.dependencies] @@ -371,7 +398,7 @@ zipp = ">=0.5" [package.extras] docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] perf = ["ipython"] -testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] +testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] [[package]] name = "iniconfig" @@ -386,13 +413,13 @@ files = [ [[package]] name = "macholib" -version = "1.16.2" +version = "1.16.3" description = "Mach-O header analysis and editing" optional = false python-versions = "*" files = [ - {file = "macholib-1.16.2-py2.py3-none-any.whl", hash = "sha256:44c40f2cd7d6726af8fa6fe22549178d3a4dfecc35a9cd15ea916d9c83a688e0"}, - {file = "macholib-1.16.2.tar.gz", hash = "sha256:557bbfa1bb255c20e9abafe7ed6cd8046b48d9525db2f9b77d3122a63a2a8bf8"}, + {file = "macholib-1.16.3-py2.py3-none-any.whl", hash = "sha256:0e315d7583d38b8c77e815b1ecbdbf504a8258d8b3e17b61165c6feb60d18f2c"}, + {file = "macholib-1.16.3.tar.gz", hash = "sha256:07ae9e15e8e4cd9a788013d81f5908b3609aa76f9b1421bae9c4d7606ec86a30"}, ] [package.dependencies] @@ -444,24 +471,24 @@ files = [ [[package]] name = "packaging" -version = "23.1" +version = "23.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.7" files = [ - {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, - {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, + {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, + {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, ] [[package]] name = "pathspec" -version = "0.11.1" +version = "0.11.2" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.7" files = [ - {file = "pathspec-0.11.1-py3-none-any.whl", hash = "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293"}, - {file = "pathspec-0.11.1.tar.gz", hash = "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687"}, + {file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"}, + {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, ] [[package]] @@ -477,31 +504,31 @@ files = [ [[package]] name = "platformdirs" -version = "3.8.0" +version = "3.11.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." optional = false python-versions = ">=3.7" files = [ - {file = "platformdirs-3.8.0-py3-none-any.whl", hash = "sha256:ca9ed98ce73076ba72e092b23d3c93ea6c4e186b3f1c3dad6edd98ff6ffcca2e"}, - {file = "platformdirs-3.8.0.tar.gz", hash = "sha256:b0cabcb11063d21a0b261d557acb0a9d2126350e63b70cdf7db6347baea456dc"}, + {file = "platformdirs-3.11.0-py3-none-any.whl", hash = "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e"}, + {file = "platformdirs-3.11.0.tar.gz", hash = "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3"}, ] [package.dependencies] -typing-extensions = {version = ">=4.6.3", markers = "python_version < \"3.8\""} +typing-extensions = {version = ">=4.7.1", markers = "python_version < \"3.8\""} [package.extras] -docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)"] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] [[package]] name = "pluggy" -version = "1.0.0" +version = "1.2.0" description = "plugin and hook calling mechanisms for python" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, - {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, + {file = "pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849"}, + {file = "pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3"}, ] [package.dependencies] @@ -513,23 +540,23 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pyinstaller" -version = "5.11.0" +version = "5.13.2" description = "PyInstaller bundles a Python application and all its dependencies into a single package." optional = false -python-versions = "<3.12,>=3.7" +python-versions = "<3.13,>=3.7" files = [ - {file = "pyinstaller-5.11.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:8454bac8f3cb2219a3ce2227fd039bdaf943dcba60e8c55732958ea3a6d81263"}, - {file = "pyinstaller-5.11.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:b3c6299fd7526c6ca87ea5f9017fb1928d47046df0b9f983d6bbd893801010dc"}, - {file = "pyinstaller-5.11.0-py3-none-manylinux2014_i686.whl", hash = "sha256:e359571327bbef434fc61324891399f9117efbb685b5065234eebb01713650a8"}, - {file = "pyinstaller-5.11.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:a445a91b85c9a1ea3985268643a674900dd86f244cc4be4ff4ec4c6367ff99a9"}, - {file = "pyinstaller-5.11.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2a1fe6d0da22f207cfa4b3221fe365503cba071c77aac19f76a75503f67d9ff9"}, - {file = "pyinstaller-5.11.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:b4cac0e7b0d63c6a869843113008f59fd5b38b2959ffa6059e7fac4bb05de92b"}, - {file = "pyinstaller-5.11.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:0af9d11a09ce217d32f95c79c984054457b310671387ff32bae1496876308556"}, - {file = "pyinstaller-5.11.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:b8a4f6834e5c85150948e22c74dd3ab8b98aa4ccdf964d880ac14d2f78d9c1a4"}, - {file = "pyinstaller-5.11.0-py3-none-win32.whl", hash = "sha256:049cdc3524aefb5ca015a63d2c81b6bf1567cc818ac066859fbfde702c6165d3"}, - {file = "pyinstaller-5.11.0-py3-none-win_amd64.whl", hash = "sha256:42fdea67e4c2217cedd54d17d1d402736df3ba718db2b497df65df5a68ae4f93"}, - {file = "pyinstaller-5.11.0-py3-none-win_arm64.whl", hash = "sha256:036a062a228af41f6bb6370a4e87cef34858cc839200a07ace7f8738ef64ad86"}, - {file = "pyinstaller-5.11.0.tar.gz", hash = "sha256:cb87cee0b3c81ccd74d4bf3f4faf03b5e1e39bb91f1a894b2ce4cd22363bf779"}, + {file = "pyinstaller-5.13.2-py3-none-macosx_10_13_universal2.whl", hash = "sha256:16cbd66b59a37f4ee59373a003608d15df180a0d9eb1a29ff3bfbfae64b23d0f"}, + {file = "pyinstaller-5.13.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8f6dd0e797ae7efdd79226f78f35eb6a4981db16c13325e962a83395c0ec7420"}, + {file = "pyinstaller-5.13.2-py3-none-manylinux2014_i686.whl", hash = "sha256:65133ed89467edb2862036b35d7c5ebd381670412e1e4361215e289c786dd4e6"}, + {file = "pyinstaller-5.13.2-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:7d51734423685ab2a4324ab2981d9781b203dcae42839161a9ee98bfeaabdade"}, + {file = "pyinstaller-5.13.2-py3-none-manylinux2014_s390x.whl", hash = "sha256:2c2fe9c52cb4577a3ac39626b84cf16cf30c2792f785502661286184f162ae0d"}, + {file = "pyinstaller-5.13.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c63ef6133eefe36c4b2f4daf4cfea3d6412ece2ca218f77aaf967e52a95ac9b8"}, + {file = "pyinstaller-5.13.2-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:aadafb6f213549a5906829bb252e586e2cf72a7fbdb5731810695e6516f0ab30"}, + {file = "pyinstaller-5.13.2-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:b2e1c7f5cceb5e9800927ddd51acf9cc78fbaa9e79e822c48b0ee52d9ce3c892"}, + {file = "pyinstaller-5.13.2-py3-none-win32.whl", hash = "sha256:421cd24f26144f19b66d3868b49ed673176765f92fa9f7914cd2158d25b6d17e"}, + {file = "pyinstaller-5.13.2-py3-none-win_amd64.whl", hash = "sha256:ddcc2b36052a70052479a9e5da1af067b4496f43686ca3cdda99f8367d0627e4"}, + {file = "pyinstaller-5.13.2-py3-none-win_arm64.whl", hash = "sha256:27cd64e7cc6b74c5b1066cbf47d75f940b71356166031deb9778a2579bb874c6"}, + {file = "pyinstaller-5.13.2.tar.gz", hash = "sha256:c8e5d3489c3a7cc5f8401c2d1f48a70e588f9967e391c3b06ddac1f685f8d5d2"}, ] [package.dependencies] @@ -538,7 +565,7 @@ importlib-metadata = {version = ">=1.4", markers = "python_version < \"3.8\""} macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""} pefile = {version = ">=2022.5.30", markers = "sys_platform == \"win32\""} pyinstaller-hooks-contrib = ">=2021.4" -pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""} +pywin32-ctypes = {version = ">=0.2.1", markers = "sys_platform == \"win32\""} setuptools = ">=42.0.0" [package.extras] @@ -547,24 +574,24 @@ hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"] [[package]] name = "pyinstaller-hooks-contrib" -version = "2023.3" +version = "2023.9" description = "Community maintained hooks for PyInstaller" optional = false python-versions = ">=3.7" files = [ - {file = "pyinstaller-hooks-contrib-2023.3.tar.gz", hash = "sha256:bb39e1038e3e0972420455e0b39cd9dce73f3d80acaf4bf2b3615fea766ff370"}, - {file = "pyinstaller_hooks_contrib-2023.3-py2.py3-none-any.whl", hash = "sha256:062ad7a1746e1cfc24d3a8c4be4e606fced3b123bda7d419f14fcf7507804b07"}, + {file = "pyinstaller-hooks-contrib-2023.9.tar.gz", hash = "sha256:76084b5988e3957a9df169d2a935d65500136967e710ddebf57263f1a909cd80"}, + {file = "pyinstaller_hooks_contrib-2023.9-py2.py3-none-any.whl", hash = "sha256:f34f4c6807210025c8073ebe665f422a3aa2ac5f4c7ebf4c2a26cc77bebf63b5"}, ] [[package]] name = "pytest" -version = "7.3.1" +version = "7.3.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.7" files = [ - {file = "pytest-7.3.1-py3-none-any.whl", hash = "sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362"}, - {file = "pytest-7.3.1.tar.gz", hash = "sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3"}, + {file = "pytest-7.3.2-py3-none-any.whl", hash = "sha256:cdcbd012c9312258922f8cd3f1b62a6580fdced17db6014896053d47cddf9295"}, + {file = "pytest-7.3.2.tar.gz", hash = "sha256:ee990a3cc55ba808b80795a79944756f315c67c12b56abd3ac993a7b8c17030b"}, ] [package.dependencies] @@ -577,7 +604,7 @@ pluggy = ">=0.12,<2.0" tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-mock" @@ -612,62 +639,62 @@ six = ">=1.5" [[package]] name = "pywin32-ctypes" -version = "0.2.0" -description = "" +version = "0.2.2" +description = "A (partial) reimplementation of pywin32 using ctypes/cffi" optional = false -python-versions = "*" +python-versions = ">=3.6" files = [ - {file = "pywin32-ctypes-0.2.0.tar.gz", hash = "sha256:24ffc3b341d457d48e8922352130cf2644024a4ff09762a2261fd34c36ee5942"}, - {file = "pywin32_ctypes-0.2.0-py2.py3-none-any.whl", hash = "sha256:9dc2d991b3479cc2df15930958b674a48a227d5361d413827a4cfd0b5876fc98"}, + {file = "pywin32-ctypes-0.2.2.tar.gz", hash = "sha256:3426e063bdd5fd4df74a14fa3cf80a0b42845a87e1d1e81f6549f9daec593a60"}, + {file = "pywin32_ctypes-0.2.2-py3-none-any.whl", hash = "sha256:bf490a1a709baf35d688fe0ecf980ed4de11d2b3e37b51e5442587a75d9957e7"}, ] [[package]] name = "pyyaml" -version = "6.0" +version = "6.0.1" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.6" files = [ - {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, - {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, - {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, - {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, - {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, - {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, - {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, - {file = "PyYAML-6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358"}, - {file = "PyYAML-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1"}, - {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d"}, - {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f"}, - {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782"}, - {file = "PyYAML-6.0-cp311-cp311-win32.whl", hash = "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7"}, - {file = "PyYAML-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf"}, - {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, - {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, - {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, - {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, - {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, - {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, - {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, - {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, - {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, - {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, - {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, - {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, - {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, - {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, - {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, - {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, - {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, - {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, - {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, - {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, - {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, - {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, - {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, - {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, - {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, - {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, + {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, + {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, + {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, + {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, + {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, + {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, ] [[package]] @@ -693,21 +720,21 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "responses" -version = "0.23.1" +version = "0.23.3" description = "A utility library for mocking out the `requests` Python library." optional = false python-versions = ">=3.7" files = [ - {file = "responses-0.23.1-py3-none-any.whl", hash = "sha256:8a3a5915713483bf353b6f4079ba8b2a29029d1d1090a503c70b0dc5d9d0c7bd"}, - {file = "responses-0.23.1.tar.gz", hash = "sha256:c4d9aa9fc888188f0c673eff79a8dadbe2e75b7fe879dc80a221a06e0a68138f"}, + {file = "responses-0.23.3-py3-none-any.whl", hash = "sha256:e6fbcf5d82172fecc0aa1860fd91e58cbfd96cee5e96da5b63fa6eb3caa10dd3"}, + {file = "responses-0.23.3.tar.gz", hash = "sha256:205029e1cb334c21cb4ec64fc7599be48b859a0fd381a42443cdd600bfe8b16a"}, ] [package.dependencies] pyyaml = "*" -requests = ">=2.22.0,<3.0" +requests = ">=2.30.0,<3.0" types-PyYAML = "*" typing-extensions = {version = "*", markers = "python_version < \"3.8\""} -urllib3 = ">=1.25.10" +urllib3 = ">=1.25.10,<3.0" [package.extras] tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli", "tomli-w", "types-requests"] @@ -740,18 +767,18 @@ files = [ [[package]] name = "setuptools" -version = "67.7.2" +version = "68.0.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.7" files = [ - {file = "setuptools-67.7.2-py3-none-any.whl", hash = "sha256:23aaf86b85ca52ceb801d32703f12d77517b2556af839621c641fca11287952b"}, - {file = "setuptools-67.7.2.tar.gz", hash = "sha256:f104fa03692a2602fa0fec6c6a9e63b6c8a968de13e17c026957dd1f53d80990"}, + {file = "setuptools-68.0.0-py3-none-any.whl", hash = "sha256:11e52c67415a381d10d6b462ced9cfb97066179f0e871399e006c4ab101fc85f"}, + {file = "setuptools-68.0.0.tar.gz", hash = "sha256:baf1fdb41c6da4cd2eae722e135500da913332ab3f2f5c7d33af9b492acb5235"}, ] [package.extras] docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] [[package]] @@ -767,13 +794,13 @@ files = [ [[package]] name = "smmap" -version = "5.0.0" +version = "5.0.1" description = "A pure Python implementation of a sliding window memory map manager" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "smmap-5.0.0-py3-none-any.whl", hash = "sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94"}, - {file = "smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"}, + {file = "smmap-5.0.1-py3-none-any.whl", hash = "sha256:e6d8668fa5f93e706934a62d7b4db19c8d9eb8cf2adbb75ef1b675aa332b69da"}, + {file = "smmap-5.0.1.tar.gz", hash = "sha256:dceeb6c0028fdb6734471eb07c0cd2aae706ccaecab45965ee83f11c8d3b1f62"}, ] [[package]] @@ -850,13 +877,13 @@ files = [ [[package]] name = "types-pyyaml" -version = "6.0.12.9" +version = "6.0.12.12" description = "Typing stubs for PyYAML" optional = false python-versions = "*" files = [ - {file = "types-PyYAML-6.0.12.9.tar.gz", hash = "sha256:c51b1bd6d99ddf0aa2884a7a328810ebf70a4262c292195d3f4f9a0005f9eeb6"}, - {file = "types_PyYAML-6.0.12.9-py3-none-any.whl", hash = "sha256:5aed5aa66bd2d2e158f75dda22b059570ede988559f030cf294871d3b647e3e8"}, + {file = "types-PyYAML-6.0.12.12.tar.gz", hash = "sha256:334373d392fde0fdf95af5c3f1661885fa10c52167b14593eb856289e1855062"}, + {file = "types_PyYAML-6.0.12.12-py3-none-any.whl", hash = "sha256:c05bc6c158facb0676674b7f11fe3960db4f389718e19e62bd2b84d6205cfd24"}, ] [[package]] @@ -903,5 +930,5 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" -python-versions = ">=3.7,<3.12" -content-hash = "c02a52cf933b218c70b12ec1b1cc0cd53ee8d43c3ad3a0ceb0a8fb23a5e0b0c9" +python-versions = ">=3.7,<3.13" +content-hash = "a82953605d241f689dba7d16b5500360d09aadbb26b7484a16f5bef5e298eb49" diff --git a/pyproject.toml b/pyproject.toml index e83d552e..adf79415 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,13 +20,14 @@ classifiers = [ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ] [tool.poetry.scripts] cycode = "cycode.cli.main:main_cli" [tool.poetry.dependencies] -python = ">=3.7,<3.12" +python = ">=3.7,<3.13" click = ">=8.1.0,<8.2.0" colorama = ">=0.4.3,<0.5.0" pyyaml = ">=6.0,<7.0" @@ -47,7 +48,7 @@ coverage = ">=7.2.3,<7.3.0" responses = ">=0.23.1,<0.24.0" [tool.poetry.group.executable.dependencies] -pyinstaller = ">=5.11.0,<5.12.0" +pyinstaller = ">=5.13.0,<5.14.0" dunamai = ">=1.16.1,<1.17.0" [tool.poetry.group.dev.dependencies] From 47e2b2d662053a01457a857e5108c1a8e41cea01 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 10 Oct 2023 11:29:48 +0200 Subject: [PATCH 035/257] CM-27690 - Configure API URL and APP URL using "cycode configure" (#167) --- cycode/cli/commands/configure/__init__.py | 0 .../commands/configure/configure_command.py | 137 +++++++++++ cycode/cli/commands/ignore/__init__.py | 0 .../ignore/ignore_command.py} | 79 +------ cycode/cli/main.py | 7 +- .../cli/user_settings/config_file_manager.py | 8 +- .../user_settings/configuration_manager.py | 4 - tests/cli/test_configure_command.py | 220 ++++++++++++++++++ .../test_user_settings_commands.py | 123 ---------- 9 files changed, 374 insertions(+), 204 deletions(-) create mode 100644 cycode/cli/commands/configure/__init__.py create mode 100644 cycode/cli/commands/configure/configure_command.py create mode 100644 cycode/cli/commands/ignore/__init__.py rename cycode/cli/{user_settings/user_settings_commands.py => commands/ignore/ignore_command.py} (56%) create mode 100644 tests/cli/test_configure_command.py delete mode 100644 tests/user_settings/test_user_settings_commands.py diff --git a/cycode/cli/commands/configure/__init__.py b/cycode/cli/commands/configure/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/commands/configure/configure_command.py b/cycode/cli/commands/configure/configure_command.py new file mode 100644 index 00000000..5f9bad0e --- /dev/null +++ b/cycode/cli/commands/configure/configure_command.py @@ -0,0 +1,137 @@ +from typing import Optional + +import click + +from cycode.cli import config, consts +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 + +_URLS_UPDATED_SUCCESSFULLY_MESSAGE = 'Successfully configured Cycode URLs! Saved to: {filename}' +_URLS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE = ( + 'Note that the URLs (APP and API) that already exist in environment variables ' + f'({consts.CYCODE_API_URL_ENV_VAR_NAME} and {consts.CYCODE_APP_URL_ENV_VAR_NAME}) ' + 'take precedent over these URLs; either update or remove the environment variables.' +) +_CREDENTIALS_UPDATED_SUCCESSFULLY_MESSAGE = 'Successfully configured CLI credentials! Saved to: {filename}' +_CREDENTIALS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE = ( + 'Note that the credentials that already exist in environment variables ' + f'({config.CYCODE_CLIENT_ID_ENV_VAR_NAME} and {config.CYCODE_CLIENT_SECRET_ENV_VAR_NAME}) ' + 'take precedent over these credentials; either update or remove the environment variables.' +) +_CREDENTIALS_MANAGER = CredentialsManager() +_CONFIGURATION_MANAGER = ConfigurationManager() + + +@click.command(short_help='Initial command to configure your CLI client authentication.') +def configure_command() -> None: + """Configure your CLI client authentication manually.""" + global_config_manager = _CONFIGURATION_MANAGER.global_config_file_manager + + current_api_url = global_config_manager.get_api_url() + current_app_url = global_config_manager.get_app_url() + api_url = _get_api_url_input(current_api_url) + app_url = _get_app_url_input(current_app_url) + + config_updated = False + if _should_update_value(current_api_url, api_url): + global_config_manager.update_api_base_url(api_url) + config_updated = True + if _should_update_value(current_app_url, app_url): + global_config_manager.update_app_base_url(app_url) + config_updated = True + + current_client_id, current_client_secret = _CREDENTIALS_MANAGER.get_credentials_from_file() + client_id = _get_client_id_input(current_client_id) + client_secret = _get_client_secret_input(current_client_secret) + + credentials_updated = False + if _should_update_value(current_client_id, client_id) or _should_update_value(current_client_secret, client_secret): + credentials_updated = True + _CREDENTIALS_MANAGER.update_credentials_file(client_id, client_secret) + + if config_updated: + click.echo(_get_urls_update_result_message()) + if credentials_updated: + click.echo(_get_credentials_update_result_message()) + + +def _get_client_id_input(current_client_id: Optional[str]) -> Optional[str]: + prompt_text = 'Cycode Client ID' + + prompt_suffix = ' []: ' + if current_client_id: + prompt_suffix = f' [{obfuscate_text(current_client_id)}]: ' + + new_client_id = click.prompt(text=prompt_text, prompt_suffix=prompt_suffix, default='', show_default=False) + return new_client_id or current_client_id + + +def _get_client_secret_input(current_client_secret: Optional[str]) -> Optional[str]: + prompt_text = 'Cycode Client Secret' + + prompt_suffix = ' []: ' + if current_client_secret: + prompt_suffix = f' [{obfuscate_text(current_client_secret)}]: ' + + new_client_secret = click.prompt(text=prompt_text, prompt_suffix=prompt_suffix, default='', show_default=False) + return new_client_secret or current_client_secret + + +def _get_app_url_input(current_app_url: Optional[str]) -> str: + prompt_text = 'Cycode APP URL' + + default = consts.DEFAULT_CYCODE_APP_URL + if current_app_url: + default = current_app_url + + return click.prompt(text=prompt_text, default=default, type=click.STRING) + + +def _get_api_url_input(current_api_url: Optional[str]) -> str: + prompt_text = 'Cycode API URL' + + default = consts.DEFAULT_CYCODE_API_URL + if current_api_url: + default = current_api_url + + return click.prompt(text=prompt_text, default=default, type=click.STRING) + + +def _get_credentials_update_result_message() -> str: + success_message = _CREDENTIALS_UPDATED_SUCCESSFULLY_MESSAGE.format(filename=_CREDENTIALS_MANAGER.get_filename()) + if _are_credentials_exist_in_environment_variables(): + return f'{success_message}. {_CREDENTIALS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE}' + + return success_message + + +def _are_credentials_exist_in_environment_variables() -> bool: + client_id, client_secret = _CREDENTIALS_MANAGER.get_credentials_from_environment_variables() + return any([client_id, client_secret]) + + +def _get_urls_update_result_message() -> str: + success_message = _URLS_UPDATED_SUCCESSFULLY_MESSAGE.format( + filename=_CONFIGURATION_MANAGER.global_config_file_manager.get_filename() + ) + if _are_urls_exist_in_environment_variables(): + return f'{success_message}. {_URLS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE}' + + return success_message + + +def _are_urls_exist_in_environment_variables() -> bool: + api_url = _CONFIGURATION_MANAGER.get_api_url_from_environment_variables() + app_url = _CONFIGURATION_MANAGER.get_app_url_from_environment_variables() + return any([api_url, app_url]) + + +def _should_update_value( + old_value: Optional[str], + new_value: Optional[str], +) -> bool: + if not new_value: + return False + + return old_value != new_value diff --git a/cycode/cli/commands/ignore/__init__.py b/cycode/cli/commands/ignore/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/user_settings/user_settings_commands.py b/cycode/cli/commands/ignore/ignore_command.py similarity index 56% rename from cycode/cli/user_settings/user_settings_commands.py rename to cycode/cli/commands/ignore/ignore_command.py index 9629de1e..66515447 100644 --- a/cycode/cli/user_settings/user_settings_commands.py +++ b/cycode/cli/commands/ignore/ignore_command.py @@ -1,41 +1,21 @@ -import os.path +import os import re -from typing import Optional import click from cycode.cli import consts from cycode.cli.config import config, configuration_manager -from cycode.cli.user_settings.credentials_manager import CredentialsManager from cycode.cli.utils.path_utils import get_absolute_path -from cycode.cli.utils.string_utils import hash_string_to_sha256, obfuscate_text +from cycode.cli.utils.string_utils import hash_string_to_sha256 from cycode.cyclient import logger -CREDENTIALS_UPDATED_SUCCESSFULLY_MESSAGE = 'Successfully configured CLI credentials!' -CREDENTIALS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE = ( - 'Note that the credentials that already exist in environment' - ' variables (CYCODE_CLIENT_ID and CYCODE_CLIENT_SECRET) take' - ' precedent over these credentials; either update or remove ' - 'the environment variables.' -) -credentials_manager = CredentialsManager() - -@click.command( - short_help='Initial command to authenticate your CLI client with Cycode using a client ID and client secret.' -) -def set_credentials() -> None: - """Authenticates your CLI client with Cycode manually by using a client ID and client secret.""" - click.echo(f'Update credentials in file ({credentials_manager.get_filename()})') - current_client_id, current_client_secret = credentials_manager.get_credentials_from_file() - client_id = _get_client_id_input(current_client_id) - client_secret = _get_client_secret_input(current_client_secret) +def _is_path_to_ignore_exists(path: str) -> bool: + return os.path.exists(path) - if not _should_update_credentials(current_client_id, current_client_secret, client_id, client_secret): - return - credentials_manager.update_credentials_file(client_id, client_secret) - click.echo(_get_credentials_update_result_message()) +def _is_package_pattern_valid(package: str) -> bool: + return re.search('^[^@]+@[^@]+$', package) is not None @click.command(short_help='Ignores a specific value, path or rule ID.') @@ -83,7 +63,7 @@ def set_credentials() -> None: required=False, help='Add an ignore rule to the global CLI config.', ) -def add_exclusions( +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.""" @@ -126,48 +106,3 @@ def add_exclusions( }, ) configuration_manager.add_exclusion(configuration_scope, scan_type, exclusion_type, exclusion_value) - - -def _get_client_id_input(current_client_id: str) -> str: - new_client_id = click.prompt( - f'cycode client id [{_obfuscate_credential(current_client_id)}]', default='', show_default=False - ) - - return new_client_id if new_client_id else current_client_id - - -def _get_client_secret_input(current_client_secret: str) -> str: - new_client_secret = click.prompt( - f'cycode client secret [{_obfuscate_credential(current_client_secret)}]', default='', show_default=False - ) - return new_client_secret if new_client_secret else current_client_secret - - -def _get_credentials_update_result_message() -> str: - if not _are_credentials_exist_in_environment_variables(): - return CREDENTIALS_UPDATED_SUCCESSFULLY_MESSAGE - - return CREDENTIALS_UPDATED_SUCCESSFULLY_MESSAGE + ' ' + CREDENTIALS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE - - -def _are_credentials_exist_in_environment_variables() -> bool: - client_id, client_secret = credentials_manager.get_credentials_from_environment_variables() - return client_id is not None or client_secret is not None - - -def _should_update_credentials( - current_client_id: str, current_client_secret: str, new_client_id: str, new_client_secret: str -) -> bool: - return current_client_id != new_client_id or current_client_secret != new_client_secret - - -def _obfuscate_credential(credential: Optional[str]) -> str: - return '' if not credential else obfuscate_text(credential) - - -def _is_path_to_ignore_exists(path: str) -> bool: - return os.path.exists(path) - - -def _is_package_pattern_valid(package: str) -> bool: - return re.search('^[^@]+@[^@]+$', package) is not None diff --git a/cycode/cli/main.py b/cycode/cli/main.py index efa2b200..082cca3a 100644 --- a/cycode/cli/main.py +++ b/cycode/cli/main.py @@ -8,6 +8,8 @@ from cycode import __version__ from cycode.cli import code_scanner from cycode.cli.auth.auth_command import authenticate +from cycode.cli.commands.configure.configure_command import configure_command +from cycode.cli.commands.ignore.ignore_command import ignore_command from cycode.cli.commands.report.report_command import report_command from cycode.cli.config import config from cycode.cli.consts import ( @@ -19,7 +21,6 @@ ) from cycode.cli.models import Severity from cycode.cli.user_settings.configuration_manager import ConfigurationManager -from cycode.cli.user_settings.user_settings_commands import add_exclusions, set_credentials from cycode.cli.utils import scan_utils from cycode.cli.utils.get_api_client import get_scan_cycode_client from cycode.cli.utils.progress_bar import SCAN_PROGRESS_BAR_SECTIONS, get_progress_bar @@ -183,8 +184,8 @@ def version(context: click.Context) -> None: commands={ 'scan': code_scan, 'report': report_command, - 'configure': set_credentials, - 'ignore': add_exclusions, + 'configure': configure_command, + 'ignore': ignore_command, 'auth': authenticate, 'version': version, }, diff --git a/cycode/cli/user_settings/config_file_manager.py b/cycode/cli/user_settings/config_file_manager.py index acec4d17..e4e5e6b1 100644 --- a/cycode/cli/user_settings/config_file_manager.py +++ b/cycode/cli/user_settings/config_file_manager.py @@ -52,8 +52,12 @@ def get_exclude_detections_in_deleted_lines(self, command_scan_type: str) -> Opt command_scan_type, self.EXCLUDE_DETECTIONS_IN_DELETED_LINES ) - def update_base_url(self, base_url: str) -> None: - update_data = {self.ENVIRONMENT_SECTION_NAME: {self.API_URL_FIELD_NAME: base_url}} + def update_api_base_url(self, api_url: str) -> None: + update_data = {self.ENVIRONMENT_SECTION_NAME: {self.API_URL_FIELD_NAME: api_url}} + self.write_content_to_file(update_data) + + def update_app_base_url(self, app_url: str) -> None: + update_data = {self.ENVIRONMENT_SECTION_NAME: {self.APP_URL_FIELD_NAME: app_url}} self.write_content_to_file(update_data) def get_installation_id(self) -> Optional[str]: diff --git a/cycode/cli/user_settings/configuration_manager.py b/cycode/cli/user_settings/configuration_manager.py index 65da08fc..4ceec970 100644 --- a/cycode/cli/user_settings/configuration_manager.py +++ b/cycode/cli/user_settings/configuration_manager.py @@ -76,10 +76,6 @@ def _merge_exclusions(self, local_exclusions: Dict, global_exclusions: Dict) -> keys = set(list(local_exclusions.keys()) + list(global_exclusions.keys())) return {key: local_exclusions.get(key, []) + global_exclusions.get(key, []) for key in keys} - def update_base_url(self, base_url: str, scope: str = 'local') -> None: - config_file_manager = self.get_config_file_manager(scope) - config_file_manager.update_base_url(base_url) - def get_or_create_installation_id(self) -> str: config_file_manager = self.get_config_file_manager() diff --git a/tests/cli/test_configure_command.py b/tests/cli/test_configure_command.py new file mode 100644 index 00000000..4c42971b --- /dev/null +++ b/tests/cli/test_configure_command.py @@ -0,0 +1,220 @@ +from typing import TYPE_CHECKING + +from click.testing import CliRunner + +from cycode.cli.commands.configure.configure_command import configure_command + +if TYPE_CHECKING: + from pytest_mock import MockerFixture + + +def test_configure_command_no_exist_values_in_file(mocker: 'MockerFixture') -> None: + # Arrange + app_url_user_input = 'new app url' + api_url_user_input = 'new api url' + client_id_user_input = 'new client id' + client_secret_user_input = 'new client secret' + + mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', + return_value=(None, None), + ) + mocker.patch( + 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_api_url', + return_value=None, + ) + mocker.patch( + 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_app_url', + return_value=None, + ) + + # side effect - multiple return values, each item in the list represents return of a call + mocker.patch( + 'click.prompt', + side_effect=[api_url_user_input, app_url_user_input, client_id_user_input, client_secret_user_input], + ) + + mocked_update_credentials = mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials_file' + ) + mocked_update_api_base_url = mocker.patch( + 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_api_base_url' + ) + mocked_update_app_base_url = mocker.patch( + 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_app_base_url' + ) + + # Act + CliRunner().invoke(configure_command) + + # Assert + mocked_update_credentials.assert_called_once_with(client_id_user_input, client_secret_user_input) + mocked_update_api_base_url.assert_called_once_with(api_url_user_input) + mocked_update_app_base_url.assert_called_once_with(app_url_user_input) + + +def test_configure_command_update_current_configs_in_files(mocker: 'MockerFixture') -> None: + # Arrange + app_url_user_input = 'new app url' + api_url_user_input = 'new api url' + client_id_user_input = 'new client id' + client_secret_user_input = 'new client secret' + + mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', + return_value=('client id file', 'client secret file'), + ) + mocker.patch( + 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_api_url', + return_value='api url file', + ) + mocker.patch( + 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_app_url', + return_value='app url file', + ) + + # side effect - multiple return values, each item in the list represents return of a call + mocker.patch( + 'click.prompt', + side_effect=[api_url_user_input, app_url_user_input, client_id_user_input, client_secret_user_input], + ) + + mocked_update_credentials = mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials_file' + ) + mocked_update_api_base_url = mocker.patch( + 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_api_base_url' + ) + mocked_update_app_base_url = mocker.patch( + 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_app_base_url' + ) + + # Act + CliRunner().invoke(configure_command) + + # Assert + mocked_update_credentials.assert_called_once_with(client_id_user_input, client_secret_user_input) + mocked_update_api_base_url.assert_called_once_with(api_url_user_input) + mocked_update_app_base_url.assert_called_once_with(app_url_user_input) + + +def test_set_credentials_update_only_client_id(mocker: 'MockerFixture') -> None: + # Arrange + client_id_user_input = 'new client id' + current_client_id = 'client secret file' + mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', + return_value=('client id file', 'client secret file'), + ) + + # side effect - multiple return values, each item in the list represents return of a call + mocker.patch('click.prompt', side_effect=['', '', client_id_user_input, '']) + mocked_update_credentials = mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials_file' + ) + + # Act + CliRunner().invoke(configure_command) + + # Assert + mocked_update_credentials.assert_called_once_with(client_id_user_input, current_client_id) + + +def test_configure_command_update_only_client_secret(mocker: 'MockerFixture') -> None: + # Arrange + client_secret_user_input = 'new client secret' + current_client_id = 'client secret file' + + mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', + return_value=(current_client_id, 'client secret file'), + ) + + # side effect - multiple return values, each item in the list represents return of a call + mocker.patch('click.prompt', side_effect=['', '', '', client_secret_user_input]) + mocked_update_credentials = mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials_file' + ) + + # Act + CliRunner().invoke(configure_command) + + # Assert + mocked_update_credentials.assert_called_once_with(current_client_id, client_secret_user_input) + + +def test_configure_command_update_only_api_url(mocker: 'MockerFixture') -> None: + # Arrange + api_url_user_input = 'new api url' + current_api_url = 'api url' + + mocker.patch( + 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_api_url', + return_value=current_api_url, + ) + + # side effect - multiple return values, each item in the list represents return of a call + mocker.patch('click.prompt', side_effect=[api_url_user_input, '', '', '']) + mocked_update_api_base_url = mocker.patch( + 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_api_base_url' + ) + + # Act + CliRunner().invoke(configure_command) + + # Assert + mocked_update_api_base_url.assert_called_once_with(api_url_user_input) + + +def test_configure_command_should_not_update_credentials_file(mocker: 'MockerFixture') -> None: + # Arrange + client_id_user_input = '' + client_secret_user_input = '' + + mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', + return_value=('client id file', 'client secret file'), + ) + + # side effect - multiple return values, each item in the list represents return of a call + mocker.patch('click.prompt', side_effect=['', '', client_id_user_input, client_secret_user_input]) + mocked_update_credentials = mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials_file' + ) + + # Act + CliRunner().invoke(configure_command) + + # Assert + assert not mocked_update_credentials.called + + +def test_configure_command_should_not_update_config_file(mocker: 'MockerFixture') -> None: + # Arrange + app_url_user_input = '' + api_url_user_input = '' + + mocker.patch( + 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_api_url', + return_value='api url file', + ) + mocker.patch( + 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_app_url', + return_value='app url file', + ) + + # side effect - multiple return values, each item in the list represents return of a call + mocker.patch('click.prompt', side_effect=[api_url_user_input, app_url_user_input, '', '']) + mocked_update_api_base_url = mocker.patch( + 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_api_base_url' + ) + mocked_update_app_base_url = mocker.patch( + 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_app_base_url' + ) + + # Act + CliRunner().invoke(configure_command) + + # Assert + assert not mocked_update_api_base_url.called + assert not mocked_update_app_base_url.called diff --git a/tests/user_settings/test_user_settings_commands.py b/tests/user_settings/test_user_settings_commands.py deleted file mode 100644 index 6ef0cf2e..00000000 --- a/tests/user_settings/test_user_settings_commands.py +++ /dev/null @@ -1,123 +0,0 @@ -from typing import TYPE_CHECKING - -from click.testing import CliRunner - -from cycode.cli.user_settings.user_settings_commands import set_credentials - -if TYPE_CHECKING: - from pytest_mock import MockerFixture - - -def test_set_credentials_no_exist_credentials_in_file(mocker: 'MockerFixture') -> None: - # Arrange - client_id_user_input = 'new client id' - client_secret_user_input = 'new client secret' - mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', - return_value=(None, None), - ) - - # side effect - multiple return values, each item in the list represent return of a call - mocker.patch('click.prompt', side_effect=[client_id_user_input, client_secret_user_input]) - mocked_update_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials_file' - ) - click = CliRunner() - - # Act - click.invoke(set_credentials) - - # Assert - mocked_update_credentials.assert_called_once_with(client_id_user_input, client_secret_user_input) - - -def test_set_credentials_update_current_credentials_in_file(mocker: 'MockerFixture') -> None: - # Arrange - client_id_user_input = 'new client id' - client_secret_user_input = 'new client secret' - mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', - return_value=('client id file', 'client secret file'), - ) - - # side effect - multiple return values, each item in the list represents return of a call - mocker.patch('click.prompt', side_effect=[client_id_user_input, client_secret_user_input]) - mocked_update_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials_file' - ) - click = CliRunner() - - # Act - click.invoke(set_credentials) - - # Assert - mocked_update_credentials.assert_called_once_with(client_id_user_input, client_secret_user_input) - - -def test_set_credentials_update_only_client_id(mocker: 'MockerFixture') -> None: - # Arrange - client_id_user_input = 'new client id' - current_client_id = 'client secret file' - mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', - return_value=('client id file', 'client secret file'), - ) - - # side effect - multiple return values, each item in the list represent return of a call - mocker.patch('click.prompt', side_effect=[client_id_user_input, '']) - mocked_update_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials_file' - ) - click = CliRunner() - - # Act - click.invoke(set_credentials) - - # Assert - mocked_update_credentials.assert_called_once_with(client_id_user_input, current_client_id) - - -def test_set_credentials_update_only_client_secret(mocker: 'MockerFixture') -> None: - # Arrange - client_secret_user_input = 'new client secret' - current_client_id = 'client secret file' - mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', - return_value=(current_client_id, 'client secret file'), - ) - - # side effect - multiple return values, each item in the list represent return of a call - mocker.patch('click.prompt', side_effect=['', client_secret_user_input]) - mocked_update_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials_file' - ) - click = CliRunner() - - # Act - click.invoke(set_credentials) - - # Assert - mocked_update_credentials.assert_called_once_with(current_client_id, client_secret_user_input) - - -def test_set_credentials_should_not_update_file(mocker: 'MockerFixture') -> None: - # Arrange - client_id_user_input = '' - client_secret_user_input = '' - mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', - return_value=('client id file', 'client secret file'), - ) - - # side effect - multiple return values, each item in the list represent return of a call - mocker.patch('click.prompt', side_effect=[client_id_user_input, client_secret_user_input]) - mocked_update_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials_file' - ) - click = CliRunner() - - # Act - click.invoke(set_credentials) - - # Assert - assert not mocked_update_credentials.called From a35fb4910d2007252e55330c31bd900c850719be Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 10 Oct 2023 12:34:37 +0200 Subject: [PATCH 036/257] CM-27947 - Fix required authorization for sbom --help command (#168) --- cycode/cli/commands/report/report_command.py | 4 ---- cycode/cli/commands/report/sbom/sbom_path_command.py | 3 ++- .../commands/report/sbom/sbom_repository_url_command.py | 3 ++- cycode/cyclient/client_creator.py | 4 ++-- cycode/cyclient/report_client.py | 8 ++++---- 5 files changed, 10 insertions(+), 12 deletions(-) diff --git a/cycode/cli/commands/report/report_command.py b/cycode/cli/commands/report/report_command.py index 4722a6b2..7bfb73c6 100644 --- a/cycode/cli/commands/report/report_command.py +++ b/cycode/cli/commands/report/report_command.py @@ -1,7 +1,6 @@ import click from cycode.cli.commands.report.sbom.sbom_command import sbom_command -from cycode.cli.utils.get_api_client import get_report_cycode_client from cycode.cli.utils.progress_bar import SBOM_REPORT_PROGRESS_BAR_SECTIONS, get_progress_bar @@ -16,8 +15,5 @@ def report_command( context: click.Context, ) -> int: """Generate report.""" - - context.obj['client'] = get_report_cycode_client(hide_response_log=False) # TODO disable log 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/sbom_path_command.py b/cycode/cli/commands/report/sbom/sbom_path_command.py index 36b9c4d9..23062cab 100644 --- a/cycode/cli/commands/report/sbom/sbom_path_command.py +++ b/cycode/cli/commands/report/sbom/sbom_path_command.py @@ -8,6 +8,7 @@ from cycode.cli.files_collector.path_documents import get_relevant_document 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.utils.get_api_client import get_report_cycode_client from cycode.cli.utils.progress_bar import SbomReportProgressBarSection @@ -15,7 +16,7 @@ @click.argument('path', nargs=1, type=click.Path(exists=True, resolve_path=True), required=True) @click.pass_context def sbom_path_command(context: click.Context, path: str) -> None: - client = context.obj['client'] + client = get_report_cycode_client() report_parameters = context.obj['report_parameters'] output_format = report_parameters.output_format output_file = context.obj['output_file'] diff --git a/cycode/cli/commands/report/sbom/sbom_repository_url_command.py b/cycode/cli/commands/report/sbom/sbom_repository_url_command.py index a3cb2570..4d5ee4a3 100644 --- a/cycode/cli/commands/report/sbom/sbom_repository_url_command.py +++ b/cycode/cli/commands/report/sbom/sbom_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.commands.report.sbom.handle_errors import handle_report_exception +from cycode.cli.utils.get_api_client import get_report_cycode_client from cycode.cli.utils.progress_bar import SbomReportProgressBarSection @@ -15,7 +16,7 @@ def sbom_repository_url_command(context: click.Context, uri: str) -> None: progress_bar.start() progress_bar.set_section_length(SbomReportProgressBarSection.PREPARE_LOCAL_FILES) - client = context.obj['client'] + client = get_report_cycode_client() report_parameters = context.obj['report_parameters'] output_file = context.obj['output_file'] output_format = report_parameters.output_format diff --git a/cycode/cyclient/client_creator.py b/cycode/cyclient/client_creator.py index da62bd5a..45911589 100644 --- a/cycode/cyclient/client_creator.py +++ b/cycode/cyclient/client_creator.py @@ -18,6 +18,6 @@ def create_scan_client(client_id: str, client_secret: str, hide_response_log: bo return ScanClient(client, scan_config, hide_response_log) -def create_report_client(client_id: str, client_secret: str, hide_response_log: bool) -> ReportClient: +def create_report_client(client_id: str, client_secret: str, _: bool) -> ReportClient: client = CycodeDevBasedClient(DEV_CYCODE_API_URL) if dev_mode else CycodeTokenBasedClient(client_id, client_secret) - return ReportClient(client, hide_response_log) + return ReportClient(client) diff --git a/cycode/cyclient/report_client.py b/cycode/cyclient/report_client.py index ade7d850..fa8e0c3f 100644 --- a/cycode/cyclient/report_client.py +++ b/cycode/cyclient/report_client.py @@ -37,9 +37,8 @@ class ReportClient: DOWNLOAD_REPORT_PATH: str = 'files/api/v1/file/sbom/{file_name}' # not in the report service - def __init__(self, client: CycodeClientBase, hide_response_log: bool = True) -> None: + def __init__(self, client: CycodeClientBase) -> None: self.client = client - self._hide_response_log = hide_response_log def request_sbom_report_execution( self, params: ReportParameters, zip_file: InMemoryZip = None, repository_url: Optional[str] = None @@ -55,7 +54,6 @@ def request_sbom_report_execution( request_args = { 'url_path': url_path, 'data': request_data, - 'hide_response_content_log': self._hide_response_log, } if zip_file: @@ -84,7 +82,9 @@ def get_report_execution(self, report_execution_id: int) -> models.ReportExecuti def get_file_content(self, file_name: str) -> str: response = self.client.get( - url_path=self.DOWNLOAD_REPORT_PATH.format(file_name=file_name), params={'include_hidden': True} + url_path=self.DOWNLOAD_REPORT_PATH.format(file_name=file_name), + params={'include_hidden': True}, + hide_response_content_log=True, ) return response.text From bb0413f1e88b8408e6b4c20e334d2811c139cc7a Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 10 Oct 2023 13:19:14 +0200 Subject: [PATCH 037/257] CM-27780 - Support Python 3.12 (#169) Upgrade marshmallow --- poetry.lock | 34 +++++++++++++++++++--------------- pyproject.toml | 6 +++--- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/poetry.lock b/poetry.lock index a6a0126b..06df1ccb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -13,17 +13,18 @@ files = [ [[package]] name = "arrow" -version = "0.17.0" +version = "1.2.3" description = "Better dates & times for Python" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.6" files = [ - {file = "arrow-0.17.0-py2.py3-none-any.whl", hash = "sha256:e098abbd9af3665aea81bdd6c869e93af4feb078e98468dd351c383af187aac5"}, - {file = "arrow-0.17.0.tar.gz", hash = "sha256:ff08d10cda1d36c68657d6ad20d74fbea493d980f8b2d45344e00d6ed2bf6ed4"}, + {file = "arrow-1.2.3-py3-none-any.whl", hash = "sha256:5a49ab92e3b7b71d96cd6bfcc4df14efefc9dfa96ea19045815914a6ab6b1fe2"}, + {file = "arrow-1.2.3.tar.gz", hash = "sha256:3934b30ca1b9f292376d9db15b19446088d12ec58629bc3f0da28fd55fb633a1"}, ] [package.dependencies] python-dateutil = ">=2.7.0" +typing-extensions = {version = "*", markers = "python_version < \"3.8\""} [[package]] name = "binaryornot" @@ -427,19 +428,22 @@ altgraph = ">=0.17" [[package]] name = "marshmallow" -version = "3.8.0" +version = "3.19.0" description = "A lightweight library for converting complex datatypes to and from native Python datatypes." optional = false -python-versions = ">=3.5" +python-versions = ">=3.7" files = [ - {file = "marshmallow-3.8.0-py2.py3-none-any.whl", hash = "sha256:2272273505f1644580fbc66c6b220cc78f893eb31f1ecde2af98ad28011e9811"}, - {file = "marshmallow-3.8.0.tar.gz", hash = "sha256:47911dd7c641a27160f0df5fd0fe94667160ffe97f70a42c3cc18388d86098cc"}, + {file = "marshmallow-3.19.0-py3-none-any.whl", hash = "sha256:93f0958568da045b0021ec6aeb7ac37c81bfcccbb9a0e7ed8559885070b3a19b"}, + {file = "marshmallow-3.19.0.tar.gz", hash = "sha256:90032c0fd650ce94b6ec6dc8dfeb0e3ff50c144586462c389b81a07205bedb78"}, ] +[package.dependencies] +packaging = ">=17.0" + [package.extras] -dev = ["flake8 (==3.8.3)", "flake8-bugbear (==20.1.4)", "mypy (==0.782)", "pre-commit (>=2.4,<3.0)", "pytest", "pytz", "simplejson", "tox"] -docs = ["alabaster (==0.7.12)", "autodocsumm (==0.2.0)", "sphinx (==3.2.1)", "sphinx-issues (==1.2.0)", "sphinx-version-warning (==1.1.2)"] -lint = ["flake8 (==3.8.3)", "flake8-bugbear (==20.1.4)", "mypy (==0.782)", "pre-commit (>=2.4,<3.0)"] +dev = ["flake8 (==5.0.4)", "flake8-bugbear (==22.10.25)", "mypy (==0.990)", "pre-commit (>=2.4,<3.0)", "pytest", "pytz", "simplejson", "tox"] +docs = ["alabaster (==0.7.12)", "autodocsumm (==0.2.9)", "sphinx (==5.3.0)", "sphinx-issues (==3.0.1)", "sphinx-version-warning (==1.1.2)"] +lint = ["flake8 (==5.0.4)", "flake8-bugbear (==22.10.25)", "mypy (==0.990)", "pre-commit (>=2.4,<3.0)"] tests = ["pytest", "pytz", "simplejson"] [[package]] @@ -805,13 +809,13 @@ files = [ [[package]] name = "texttable" -version = "1.6.7" +version = "1.7.0" description = "module to create simple ASCII tables" optional = false python-versions = "*" files = [ - {file = "texttable-1.6.7-py2.py3-none-any.whl", hash = "sha256:b7b68139aa8a6339d2c320ca8b1dc42d13a7831a346b446cb9eb385f0c76310c"}, - {file = "texttable-1.6.7.tar.gz", hash = "sha256:290348fb67f7746931bcdfd55ac7584ecd4e5b0846ab164333f0794b121760f2"}, + {file = "texttable-1.7.0-py2.py3-none-any.whl", hash = "sha256:72227d592c82b3d7f672731ae73e4d1f88cd8e2ef5b075a7a7f01a23a3743917"}, + {file = "texttable-1.7.0.tar.gz", hash = "sha256:2d2068fb55115807d3ac77a4ca68fa48803e84ebb0ee2340f858107a36522638"}, ] [[package]] @@ -931,4 +935,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = ">=3.7,<3.13" -content-hash = "a82953605d241f689dba7d16b5500360d09aadbb26b7484a16f5bef5e298eb49" +content-hash = "24ef8c4f25c36ad05c1f44b80fcde9ddb0cb2fc208d789f9f2e91aa62223d798" diff --git a/pyproject.toml b/pyproject.toml index adf79415..c43cf129 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,12 +31,12 @@ python = ">=3.7,<3.13" click = ">=8.1.0,<8.2.0" colorama = ">=0.4.3,<0.5.0" pyyaml = ">=6.0,<7.0" -marshmallow = ">=3.8.0,<3.9.0" +marshmallow = ">=3.15.0,<3.21.0" pathspec = ">=0.11.1,<0.12.0" gitpython = ">=3.1.30,<3.2.0" -arrow = ">=0.17.0,<0.18.0" +arrow = ">=0.17.0,<1.3.0" binaryornot = ">=0.4.4,<0.5.0" -texttable = ">=1.6.7,<1.7.0" +texttable = ">=1.6.7,<1.8.0" requests = ">=2.24,<3.0" urllib3 = "1.26.17" # lock v1 to avoid issues with openssl and old Python versions (<3.9.11) on macOS From b6c14c22f61808038c898496ce952a15c8bb79ca Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Oct 2023 12:20:10 +0200 Subject: [PATCH 038/257] Bump urllib3 from 1.26.17 to 1.26.18 (#170) Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.26.17 to 1.26.18. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/1.26.17...1.26.18) --- updated-dependencies: - dependency-name: urllib3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 18 ++++++++++++++---- pyproject.toml | 2 +- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 06df1ccb..dd0cfec3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -664,6 +664,7 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -671,8 +672,15 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -689,6 +697,7 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -696,6 +705,7 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -903,13 +913,13 @@ files = [ [[package]] name = "urllib3" -version = "1.26.17" +version = "1.26.18" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" files = [ - {file = "urllib3-1.26.17-py2.py3-none-any.whl", hash = "sha256:94a757d178c9be92ef5539b8840d48dc9cf1b2709c9d6b588232a055c524458b"}, - {file = "urllib3-1.26.17.tar.gz", hash = "sha256:24d6a242c28d29af46c3fae832c36db3bbebcc533dd1bb549172cd739c82df21"}, + {file = "urllib3-1.26.18-py2.py3-none-any.whl", hash = "sha256:34b97092d7e0a3a8cf7cd10e386f401b3737364026c45e622aa02903dffe0f07"}, + {file = "urllib3-1.26.18.tar.gz", hash = "sha256:f8ecc1bba5667413457c529ab955bf8c67b45db799d159066261719e328580a0"}, ] [package.extras] @@ -935,4 +945,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = ">=3.7,<3.13" -content-hash = "24ef8c4f25c36ad05c1f44b80fcde9ddb0cb2fc208d789f9f2e91aa62223d798" +content-hash = "5b143fd05c0d713bd821c03e16cb4c087ddd4f48c17f7dabf479fb6e67ab9cbb" diff --git a/pyproject.toml b/pyproject.toml index c43cf129..5a6ac164 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ arrow = ">=0.17.0,<1.3.0" binaryornot = ">=0.4.4,<0.5.0" texttable = ">=1.6.7,<1.8.0" requests = ">=2.24,<3.0" -urllib3 = "1.26.17" # lock v1 to avoid issues with openssl and old Python versions (<3.9.11) on macOS +urllib3 = "1.26.18" # lock v1 to avoid issues with openssl and old Python versions (<3.9.11) on macOS [tool.poetry.group.test.dependencies] mock = ">=4.0.3,<4.1.0" From f328471fbcff33f6e1ba1267d63b47a7d5e20420 Mon Sep 17 00:00:00 2001 From: jenia-sakirko <142144387+jenia-sakirko@users.noreply.github.com> Date: Thu, 19 Oct 2023 23:47:46 +0300 Subject: [PATCH 039/257] CM-28234 - Add SBOM report to the readme (#171) --- README.md | 166 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 110 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 7cdc5338..7673a272 100644 --- a/README.md +++ b/README.md @@ -15,35 +15,39 @@ This guide will guide you through both installation and usage. 1. [On Unix/Linux](#on-unixlinux) 2. [On Windows](#on-windows) 2. [Install Pre-Commit Hook](#install-pre-commit-hook) -3. [Cycode Command](#cycode-command) -4. [Running a Scan](#running-a-scan) - 1. [Repository Scan](#repository-scan) - 1. [Branch Option](#branch-option) - 2. [Monitor Option](#monitor-option) - 3. [Report Option](#report-option) - 4. [Package Vulnerabilities Scan](#package-vulnerabilities-option) - 1. [License Compliance Option](#license-compliance-option) - 2. [Severity Threshold](#severity-threshold) - 5. [Path Scan](#path-scan) - 1. [Terraform Plan Scan](#terraform-plan-scan) - 6. [Commit History Scan](#commit-history-scan) - 1. [Commit Range Option](#commit-range-option) - 7. [Pre-Commit Scan](#pre-commit-scan) -5. [Scan Results](#scan-results) - 1. [Show/Hide Secrets](#showhide-secrets) - 2. [Soft Fail](#soft-fail) - 3. [Example Scan Results](#example-scan-results) - 1. [Secrets Result Example](#secrets-result-example) - 2. [IaC Result Example](#iac-result-example) - 3. [SCA Result Example](#sca-result-example) - 4. [SAST Result Example](#sast-result-example) -6. [Ignoring Scan Results](#ignoring-scan-results) - 1. [Ignoring a Secret Value](#ignoring-a-secret-value) - 2. [Ignoring a Secret SHA Value](#ignoring-a-secret-sha-value) - 3. [Ignoring a Path](#ignoring-a-path) - 4. [Ignoring a Secret, IaC, or SCA Rule](#ignoring-a-secret-iac-sca-or-sast-rule) - 5. [Ignoring a Package](#ignoring-a-package) -7. [Syntax Help](#syntax-help) +3. [Cycode Command](#cycode-cli-commands) +4. [Scan Command](#scan-command) + 1. [Running a Scan](#running-a-scan) + 1. [Repository Scan](#repository-scan) + 1. [Branch Option](#branch-option) + 2. [Monitor Option](#monitor-option) + 3. [Report Option](#report-option) + 4. [Package Vulnerabilities Scan](#package-vulnerabilities-option) + 1. [License Compliance Option](#license-compliance-option) + 2. [Severity Threshold](#severity-threshold) + 5. [Path Scan](#path-scan) + 1. [Terraform Plan Scan](#terraform-plan-scan) + 6. [Commit History Scan](#commit-history-scan) + 1. [Commit Range Option](#commit-range-option) + 7. [Pre-Commit Scan](#pre-commit-scan) + 2. [Scan Results](#scan-results) + 1. [Show/Hide Secrets](#showhide-secrets) + 2. [Soft Fail](#soft-fail) + 3. [Example Scan Results](#example-scan-results) + 1. [Secrets Result Example](#secrets-result-example) + 2. [IaC Result Example](#iac-result-example) + 3. [SCA Result Example](#sca-result-example) + 4. [SAST Result Example](#sast-result-example) + 3. [Ignoring Scan Results](#ignoring-scan-results) + 1. [Ignoring a Secret Value](#ignoring-a-secret-value) + 2. [Ignoring a Secret SHA Value](#ignoring-a-secret-sha-value) + 3. [Ignoring a Path](#ignoring-a-path) + 4. [Ignoring a Secret, IaC, or SCA Rule](#ignoring-a-secret-iac-sca-or-sast-rule) + 5. [Ignoring a Package](#ignoring-a-package) +5. [Report command](#report-command) + 1. [Generating Report](#generating-report) + 2. [Report Result](#report-results) +6. [Syntax Help](#syntax-help) # Prerequisites @@ -226,9 +230,12 @@ The following are the options and commands available with the Cycode CLI applica | [configure](#use-configure-command) | Initial command to authenticate your CLI client with Cycode using client ID and client secret. | | [ignore](#ingoring-scan-results) | Ignore a specific value, path or rule ID. | | [scan](#running-a-scan) | Scan content for secrets/IaC/SCA/SAST violations. You need to specify which scan type: `ci`/`commit_history`/`path`/`repository`/etc. | +| [report](#running-a-report) | Generate report for SCA SBOM. | | version | Show the version and exit. | -# Running a Scan +# Scan Command + +## Running a Scan The Cycode CLI application offers several types of scans so that you can choose the option that best fits your case. The following are the current options and commands available: @@ -253,7 +260,7 @@ The Cycode CLI application offers several types of scans so that you can choose | [pre_commit](#pre-commit-scan) | Use this command to scan the content that was not committed yet | | [repository](#repository-scan) | Scan git repository including its history | -## Repository Scan +### Repository Scan A repository scan examines an entire local repository for any exposed secrets or insecure misconfigurations. This more holistic scan type looks at everything: the current state of your repository and its commit history. It will look not only for secrets that are currently exposed within the repository but previously deleted secrets as well. @@ -271,7 +278,7 @@ The following option is available for use with this command: |---------------------|-------------| | `-b, --branch TEXT` | Branch to scan, if not set scanning the default branch | -### Branch Option +#### Branch Option To scan a specific branch of your local repository, add the argument `-b` (alternatively, `--branch`) followed by the name of the branch you wish to scan. @@ -283,7 +290,7 @@ or: `cycode scan repository ~/home/git/codebase --branch dev` -## Monitor Option +### Monitor Option > :memo: **Note**
> This option is only available to SCA scans. @@ -303,7 +310,7 @@ When using this option, the scan results from this scan will appear in the knowl > :warning: **NOTE**
> You must be an `owner` or an `admin` in Cycode to view the knowledge graph page. -## Report Option +### Report Option > :memo: **Note**
> This option is only available to SCA scans. @@ -366,7 +373,7 @@ The report page will look something like below: ![](https://raw.githubusercontent.com/cycodehq-public/cycode-cli/main/images/scan_details.png) -## Package Vulnerabilities Option +### Package Vulnerabilities Option > :memo: **Note**
> This option is only available to SCA scans. @@ -381,7 +388,7 @@ or: `cycode scan --scan-type sca --sca-scan package-vulnerabilities repository ~/home/git/codebase` -### License Compliance Option +#### License Compliance Option > :memo: **Note**
> This option is only available to SCA scans. @@ -396,7 +403,7 @@ or: `cycode scan --scan-type sca --sca-scan license-compliance repository ~/home/git/codebase` -### Severity Threshold +#### Severity Threshold > :memo: **Note**
> This option is only available to SCA scans. @@ -411,7 +418,7 @@ or: `cycode scan --scan-type sca --security-threshold MEDIUM repository ~/home/git/codebase` -## Path Scan +### Path Scan A path scan examines a specific local directory and all the contents within it, instead of focusing solely on a GIT repository. @@ -424,7 +431,7 @@ For example, consider a scenario in which you want to scan the directory located `cycode scan path ~/home/git/codebase` -### Terraform Plan Scan +#### Terraform Plan Scan Cycode CLI supports Terraform plan scanning (supporting Terraform 0.12 and later) @@ -453,7 +460,7 @@ _How to generate a Terraform plan from Terraform configuration file?_ `cycode scan -t iac path ~/PATH/TO/YOUR/{tfplan}.json` -## Commit History Scan +### Commit History Scan A commit history scan is limited to a local repository’s previous commits, focused on finding any secrets within the commit history, instead of examining the repository’s current state. @@ -471,7 +478,7 @@ The following options are available for use with this command: |---------------------------|-------------| | `-r, --commit_range TEXT` | Scan a commit range in this git repository, by default cycode scans all commit history (example: HEAD~1) | -### Commit Range Option +#### Commit Range Option The commit history scan, by default, examines the repository’s entire commit history, all the way back to the initial commit. You can instead limit the scan to a specific commit range by adding the argument `--commit_range` followed by the name you specify. @@ -483,7 +490,7 @@ OR `cycode scan commit_history --commit_range {{from-commit-id}}...{{to-commit-id}} ~/home/git/codebase` -## Pre-Commit Scan +### Pre-Commit Scan A pre-commit scan automatically identifies any issues before you commit changes to your repository. There is no need to manually execute this scan; simply configure the pre-commit hook as detailed under the Installation section of this guide. @@ -491,7 +498,7 @@ After your install the pre-commit hook and, you may, on occasion, wish to skip s `SKIP=cycode git commit -m ` -# Scan Results +## Scan Results Each scan will complete with a message stating if any issues were found or not. @@ -511,7 +518,7 @@ Secret SHA: a44081db3296c84b82d12a35c446a3cba19411dddfa0380134c75f7b3973bff0 In the event an issue is found, review the file in question for the specific line highlighted by the result message. Implement any changes required to resolve the issue, then execute the scan again. -## Show/Hide Secrets +### Show/Hide Secrets In the above example, a secret was found in the file `secret_test`, located in the subfolder `cli`. The second part of the message shows the specific line the secret appears in, which in this case is a value assigned to `googleApiKey`. @@ -533,15 +540,15 @@ Secret SHA: a44081db3296c84b82d12a35c446a3cba19411dddfa0380134c75f7b3973bff0 2 | \ No newline at end of file ``` -## Soft Fail +### Soft Fail Utilizing the soft fail feature will not fail the CI/CD step within the pipeline if the Cycode scan finds an issue. Additionally, in case an issue occurs from Cycode’s side, a soft fail will automatically execute to avoid interference. Add the `--soft-fail` argument to any type of scan to configure this feature, then assign a value of `1` if you want found issues to result in a failure within the CI/CD tool or `0` for scan results to have no impact (result in a `success` result). -## Example Scan Results +### Example Scan Results -### Secrets Result Example +#### Secrets Result Example ```bash ⛔ Found issue of type: generic-password (rule ID: ce3a4de0-9dfc-448b-a004-c538cf8b4710) in file: config/my_config.py @@ -551,7 +558,7 @@ Secret SHA: a44081db3296c84b82d12a35c446a3cba19411dddfa0380134c75f7b3973bff0 2 | \ No newline at end of file ``` -### IaC Result Example +#### IaC Result Example ```bash ⛔ Found issue of type: Resource should use non-default namespace (rule ID: bdaa88e2-5e7c-46ff-ac2a-29721418c59c) in file: ./k8s/k8s.yaml ⛔ @@ -561,7 +568,7 @@ Secret SHA: a44081db3296c84b82d12a35c446a3cba19411dddfa0380134c75f7b3973bff0 9 | resourceVersion: "4228" ``` -### SCA Result Example +#### SCA Result Example ```bash ⛔ Found issue of type: Security vulnerability in package 'pyyaml' referenced in project 'Users/myuser/my-test-repo': Improper Input Validation in PyYAML (rule ID: d003b23a-a2eb-42f3-83c9-7a84505603e5) in file: Users/myuser/my-test-repo/requirements.txt ⛔ @@ -571,7 +578,7 @@ Secret SHA: a44081db3296c84b82d12a35c446a3cba19411dddfa0380134c75f7b3973bff0 3 | cleo==1.0.0a5 ``` -### SAST Result Example +#### SAST Result Example ```bash ⛔ Found issue of type: Detected a request using 'http://'. This request will be unencrypted, and attackers could listen into traffic on the network and be able to obtain sensitive information. Use 'https://' instead. (rule ID: 3fbbd34b-b00d-4415-b9d9-f861c076b9f2) in file: ./requests.py ⛔ @@ -581,7 +588,7 @@ Secret SHA: a44081db3296c84b82d12a35c446a3cba19411dddfa0380134c75f7b3973bff0 4 | print(res.content) ``` -# Ignoring Scan Results +## Ignoring Scan Results Ignore rules can be added to ignore specific secret values, specific SHA512 values, specific paths, and specific Cycode secret and IaC rule IDs. This will cause the scan to not alert these values. The ignore rules are written and saved locally in the `./.cycode/config.yaml` file. @@ -612,7 +619,7 @@ Secret SHA: a44081db3296c84b82d12a35c446a3cba19411dddfa0380134c75f7b3973bff0 If this is a value that is not a valid secret, then use the the `cycode ignore` command to ignore the secret by its value, SHA value, specific path, or rule ID. If this is an IaC scan, then you can ignore that result by its path or rule ID. -## Ignoring a Secret Value +### Ignoring a Secret Value To ignore a specific secret value, you will need to use the `--by-value` flag. This will ignore the given secret value from all future scans. Use the following command to add a secret value to be ignored: @@ -624,7 +631,7 @@ In the example at the top of this section, the command to ignore a specific secr In the example above, replace the `h3110w0r1d!@#$350` value with your non-masked secret value. See the Cycode scan options for details on how to see secret values in the scan results. -## Ignoring a Secret SHA Value +### Ignoring a Secret SHA Value To ignore a specific secret SHA value, you will need to use the `--by-sha` flag. This will ignore the given secret SHA value from all future scans. Use the following command to add a secret SHA value to be ignored: @@ -636,7 +643,7 @@ In the example at the top of this section, the command to ignore a specific secr In the example above, replace the `a44081db3296c84b82d12a35c446a3cba19411dddfa0380134c75f7b3973bff0` value with your secret SHA value. -## Ignoring a Path +### Ignoring a Path To ignore a specific path for either secret, IaC, or SCA scans, you will need to use the `--by-path` flag in conjunction with the `-t, --scan-type` flag (you must specify the scan type). This will ignore the given path from all future scans for the given scan type. Use the following command to add a path to be ignored: @@ -664,7 +671,7 @@ In the example at the top of this section, the command to ignore a specific path In the example above, replace the `~/home/my-repo/config` value with your path value. -## Ignoring a Secret, IaC, SCA, or SAST Rule +### Ignoring a Secret, IaC, SCA, or SAST Rule To ignore a specific secret, IaC, SCA, or SAST rule, you will need to use the `--by-rule` flag in conjunction with the `-t, --scan-type` flag (you must specify the scan type). This will ignore the given rule ID value from all future scans. Use the following command to add a rule ID value to be ignored: @@ -692,7 +699,7 @@ In the example at the top of this section, the command to ignore the specific SC In the example above, replace the `dc21bc6b-9f4f-46fb-9f92-e4327ea03f6b` value with the rule ID you want to ignore. -## Ignoring a Package +### Ignoring a Package > :memo: **Note**
> This option is only available to the SCA scans. @@ -711,6 +718,44 @@ In the example below, the command to ignore a specific SCA package is as follows In the example above, replace `pyyaml` with package name and `5.3.1` with the package version you want to ignore. +# Report Command + +## Generating SBOM Report + +A software bill of materials (SBOM) is an inventory of all constituent components and software dependencies involved in the development and delivery of an application. +Using this command you can create an SBOM report for your local project or for your repository URI. + +The following options are available for use with this command: +| Option | Description | Required | Default | +|---------------------|-------------|----------|---------| +| `-f, --format [spdx-2.2\|spdx-2.3\|cyclonedx-1.4]` | SBOM format | Yes | | +| `-o, --output-format [JSON]` | Specify the output file format | No | json | +| `--output-file PATH` | Output file | No | autogenerated filename saved to the current directory | +| `--include-vulnerabilities` | Include vulnerabilities | No | False | +| `--include-dev-dependencies` | Include dev dependencies | No | False | + +The following commands are available for use with this command: +| Command | Description | +|---------------------|-------------| +| `path` | Generate SBOM report for provided path in the command | +| `repository_url` | Generate SBOM report for provided repository URI in the command | + +### Repository + +To create an SBOM report for a repository URI:\ +`cycode report sbom --format --include-vulnerabilities --include-dev-dependencies --output-file
repository_url ` + +For example:\ +`cycode report sbom --format spdx-2.3 --include-vulnerabilities --include-dev-dependencies repository_url https://github.com/cycodehq-public/cycode-cli.git` + +### Local Project + +To create an SBOM report for a path:\ +`cycode report sbom --format --include-vulnerabilities --include-dev-dependencies --output-file
path
` + +For example:\ +`cycode report sbom --format spdx-2.3 --include-vulnerabilities --include-dev-dependencies path /path/to/local/project` + # Syntax Help You may add the `--help` argument to any command at any time to see a help message that will display available options and their syntax. @@ -734,3 +779,12 @@ For example, to see options available for a Path Scan, you would simply enter: To see the options available for the ignore scan function, use this command: `cycode ignore --help` + +To see the options available for report, use this command: + +`cycode report --help` + + +To see the options available for a specific type of report, enter: + +`cycode scan {{option}} --help` From 6025e9b4b3f66d7262413c3b13435180d8c94abf Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Mon, 23 Oct 2023 10:59:12 +0200 Subject: [PATCH 040/257] CM-26497 - Attach signed executables and their checksums as assets to GitHub releases (#172) --- .github/workflows/build_executable.yml | 65 ++++++++++++++++++++++---- 1 file changed, 55 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index ed88613e..daecd99f 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -1,10 +1,14 @@ -name: Build executable version of CLI +name: Build executable version of CLI and upload artifact. On dispatch event build the latest tag and upload to release assets on: + workflow_dispatch: push: branches: - main +permissions: + contents: write + jobs: build: strategy: @@ -32,10 +36,17 @@ jobs: pypi.org - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Checkout latest release tag + if: ${{ github.event_name == 'workflow_dispatch' }} + run: | + LATEST_TAG=$(git describe --tags `git rev-list --tags --max-count=1`) + git checkout $LATEST_TAG + echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV + - name: Set up Python 3.7 uses: actions/setup-python@v4 with: @@ -67,7 +78,7 @@ jobs: run: ./dist/cycode version - name: Sign macOS executable - if: ${{ startsWith(matrix.os, 'macos') }} + if: runner.os == 'macOS' env: APPLE_CERT: ${{ secrets.APPLE_CERT }} APPLE_CERT_PWD: ${{ secrets.APPLE_CERT_PWD }} @@ -92,7 +103,7 @@ jobs: codesign --deep --force --options=runtime --entitlements entitlements.plist --sign "$APPLE_CERT_NAME" --timestamp dist/cycode - name: Notarize macOS executable - if: ${{ startsWith(matrix.os, 'macos') }} + if: runner.os == 'macOS' env: APPLE_NOTARIZATION_EMAIL: ${{ secrets.APPLE_NOTARIZATION_EMAIL }} APPLE_NOTARIZATION_PWD: ${{ secrets.APPLE_NOTARIZATION_PWD }} @@ -111,11 +122,11 @@ jobs: # xcrun stapler staple dist/cycode - name: Test macOS signed executable - if: ${{ startsWith(matrix.os, 'macos') }} + if: runner.os == 'macOS' run: ./dist/cycode version - name: Import cert for Windows and setup envs - if: ${{ startsWith(matrix.os, 'windows') }} + if: runner.os == 'Windows' env: SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }} run: | @@ -128,7 +139,7 @@ jobs: echo "C:\Program Files\DigiCert\DigiCert One Signing Manager Tools" >> $GITHUB_PATH - name: Sign Windows executable - if: ${{ startsWith(matrix.os, 'windows') }} + if: runner.os == 'Windows' shell: cmd env: SM_HOST: ${{ secrets.SM_HOST }} @@ -146,7 +157,7 @@ jobs: signtool.exe sign /sha1 %SM_CODE_SIGNING_CERT_SHA1_HASH% /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 ".\dist\cycode.exe" - name: Test Windows signed executable - if: ${{ startsWith(matrix.os, 'windows') }} + if: runner.os == 'Windows' shell: cmd run: | :: call executable and expect correct output @@ -155,7 +166,41 @@ jobs: :: verify signature signtool.exe verify /v /pa ".\dist\cycode.exe" - - uses: actions/upload-artifact@v3 + - name: Prepare files on Windows + if: runner.os == 'Windows' + run: | + echo "ARTIFACT_NAME=cycode-win" >> $GITHUB_ENV + mv dist/cycode.exe dist/cycode-win.exe + powershell -Command "(Get-FileHash -Algorithm SHA256 dist/cycode-win.exe).Hash" > sha256 + head -c 64 sha256 > dist/cycode-win.exe.sha256 + + - name: Prepare files on macOS + if: runner.os == 'macOS' + run: | + echo "ARTIFACT_NAME=cycode-mac" >> $GITHUB_ENV + mv dist/cycode dist/cycode-mac + shasum -a 256 dist/cycode-mac > sha256 + head -c 64 sha256 > dist/cycode-mac.sha256 + + - name: Prepare files on Linux + if: runner.os == 'Linux' + run: | + echo "ARTIFACT_NAME=cycode-linux" >> $GITHUB_ENV + mv dist/cycode dist/cycode-linux + sha256sum dist/cycode-linux > sha256 + head -c 64 sha256 > dist/cycode-linux.sha256 + + - name: Upload files as artifact + uses: actions/upload-artifact@v3 with: - name: cycode-cli-${{ matrix.os }} + name: ${{ env.ARTIFACT_NAME }} path: dist + + - name: Upload files to release + if: ${{ github.event_name == 'workflow_dispatch' }} + uses: svenstaro/upload-release-action@v2 + with: + file: dist/* + tag: ${{ env.LATEST_TAG }} + overwrite: true + file_glob: true From 4549b2b74973e70ce0a71b815e18304c7c4aec8d Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Mon, 23 Oct 2023 11:38:30 +0200 Subject: [PATCH 041/257] CM-26497 - Fix CIMON (#173) --- .github/workflows/build_executable.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index daecd99f..a0a0e706 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -34,6 +34,7 @@ jobs: files.pythonhosted.org install.python-poetry.org pypi.org + uploads.github.com - name: Checkout repository uses: actions/checkout@v4 From bc33ae05c2cd291aa0a478dcb1a037a51f495d61 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 24 Oct 2023 11:43:18 +0200 Subject: [PATCH 042/257] CM-28389 - Update "cycode configure" section in README (#174) --- README.md | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 7673a272..ffbaf350 100644 --- a/README.md +++ b/README.md @@ -119,38 +119,50 @@ To install the Cycode CLI application on your local machine, perform the followi ### Using the Configure Command > :memo: **Note**
-> If you already set up your Cycode client ID and client secret through the Linux or Windows environment variables, those credentials will take precedent over this method. +> If you already set up your Cycode Client ID and Client Secret through the Linux or Windows environment variables, those credentials will take precedent over this method. 1. Type the following command into your terminal/command line window: `cycode configure` - You will see the following appear: +2. Enter your Cycode API URL value (you can leave blank to use default value). ```bash - Update credentials in file (/Users/travislloyd/.cycode/credentials.yaml) - cycode client id []: + Cycode API URL [https://api.cycode.com]: https://api.onpremise.com ``` -2. Enter your Cycode client ID value. +3. Enter your Cycode APP URL value (you can leave blank to use default value). ```bash - cycode client id []: 7fe5346b-xxxx-xxxx-xxxx-55157625c72d + Cycode APP URL [https://app.cycode.com]: https://app.onpremise.com ``` -3. Enter your Cycode client secret value. +4. Enter your Cycode Client ID value. ```bash - cycode client secret []: c1e24929-xxxx-xxxx-xxxx-8b08c1839a2e + Cycode Client ID []: 7fe5346b-xxxx-xxxx-xxxx-55157625c72d ``` -4. If the values were entered successfully, you'll see the following message: +5. Enter your Cycode Client Secret value. + + ```bash + Cycode Client Secret []: c1e24929-xxxx-xxxx-xxxx-8b08c1839a2e + ``` + +6. If the values were entered successfully, you'll see the following message: ```bash Successfully configured CLI credentials! ``` -If you go into the `.cycode` folder under your user folder, you'll find these credentials were created and placed in the `credentials.yaml` file in that folder. + or/and + + ```bash + Successfully configured Cycode URLs! + ``` + +If you go into the `.cycode` folder under your user folder, you'll find these credentials were created and placed in the `credentials.yaml` file in that folder. +And the URLs were placed in the `config.yaml` file in that folder. ### Add to Environment Variables From 640b58679988a7e2254e5c5eda75fb48d941e8ac Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 25 Oct 2023 16:20:11 +0200 Subject: [PATCH 043/257] CM-28473 - Upgrade dynamic versioning (fix compatibility with Poetry) (#175) --- .github/workflows/tests_full.yml | 14 +- poetry.lock | 220 +++++++++++++++---------------- pyproject.toml | 4 +- 3 files changed, 118 insertions(+), 120 deletions(-) diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index aa2c0fc2..aaa58d89 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -35,7 +35,9 @@ jobs: pypi.org - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v4 @@ -47,7 +49,7 @@ jobs: uses: actions/cache@v3 with: path: ~/.local - key: poetry-${{ matrix.os }}-0 # increment to reset cache + key: poetry-${{ matrix.os }}-1 # increment to reset cache - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' @@ -61,5 +63,11 @@ jobs: - name: Install dependencies run: poetry install - - name: Run Tests + - name: Run executable test + if: runner.os == 'Linux' + run: | + poetry run pyinstaller pyinstaller.spec + ./dist/cycode version + + - name: Run pytest run: poetry run pytest diff --git a/poetry.lock b/poetry.lock index dd0cfec3..04f7b712 100644 --- a/poetry.lock +++ b/poetry.lock @@ -114,101 +114,101 @@ files = [ [[package]] name = "charset-normalizer" -version = "3.3.0" +version = "3.3.1" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7.0" files = [ - {file = "charset-normalizer-3.3.0.tar.gz", hash = "sha256:63563193aec44bce707e0c5ca64ff69fa72ed7cf34ce6e11d5127555756fd2f6"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:effe5406c9bd748a871dbcaf3ac69167c38d72db8c9baf3ff954c344f31c4cbe"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4162918ef3098851fcd8a628bf9b6a98d10c380725df9e04caf5ca6dd48c847a"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0570d21da019941634a531444364f2482e8db0b3425fcd5ac0c36565a64142c8"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5707a746c6083a3a74b46b3a631d78d129edab06195a92a8ece755aac25a3f3d"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:278c296c6f96fa686d74eb449ea1697f3c03dc28b75f873b65b5201806346a69"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a4b71f4d1765639372a3b32d2638197f5cd5221b19531f9245fcc9ee62d38f56"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5969baeaea61c97efa706b9b107dcba02784b1601c74ac84f2a532ea079403e"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a3f93dab657839dfa61025056606600a11d0b696d79386f974e459a3fbc568ec"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:db756e48f9c5c607b5e33dd36b1d5872d0422e960145b08ab0ec7fd420e9d649"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:232ac332403e37e4a03d209a3f92ed9071f7d3dbda70e2a5e9cff1c4ba9f0678"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:e5c1502d4ace69a179305abb3f0bb6141cbe4714bc9b31d427329a95acfc8bdd"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:2502dd2a736c879c0f0d3e2161e74d9907231e25d35794584b1ca5284e43f596"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23e8565ab7ff33218530bc817922fae827420f143479b753104ab801145b1d5b"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-win32.whl", hash = "sha256:1872d01ac8c618a8da634e232f24793883d6e456a66593135aeafe3784b0848d"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:557b21a44ceac6c6b9773bc65aa1b4cc3e248a5ad2f5b914b91579a32e22204d"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d7eff0f27edc5afa9e405f7165f85a6d782d308f3b6b9d96016c010597958e63"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a685067d05e46641d5d1623d7c7fdf15a357546cbb2f71b0ebde91b175ffc3e"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0d3d5b7db9ed8a2b11a774db2bbea7ba1884430a205dbd54a32d61d7c2a190fa"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2935ffc78db9645cb2086c2f8f4cfd23d9b73cc0dc80334bc30aac6f03f68f8c"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fe359b2e3a7729010060fbca442ca225280c16e923b37db0e955ac2a2b72a05"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380c4bde80bce25c6e4f77b19386f5ec9db230df9f2f2ac1e5ad7af2caa70459"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0d1e3732768fecb052d90d62b220af62ead5748ac51ef61e7b32c266cac9293"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1b2919306936ac6efb3aed1fbf81039f7087ddadb3160882a57ee2ff74fd2382"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f8888e31e3a85943743f8fc15e71536bda1c81d5aa36d014a3c0c44481d7db6e"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:82eb849f085624f6a607538ee7b83a6d8126df6d2f7d3b319cb837b289123078"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:7b8b8bf1189b3ba9b8de5c8db4d541b406611a71a955bbbd7385bbc45fcb786c"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:5adf257bd58c1b8632046bbe43ee38c04e1038e9d37de9c57a94d6bd6ce5da34"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c350354efb159b8767a6244c166f66e67506e06c8924ed74669b2c70bc8735b1"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-win32.whl", hash = "sha256:02af06682e3590ab952599fbadac535ede5d60d78848e555aa58d0c0abbde786"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:86d1f65ac145e2c9ed71d8ffb1905e9bba3a91ae29ba55b4c46ae6fc31d7c0d4"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:3b447982ad46348c02cb90d230b75ac34e9886273df3a93eec0539308a6296d7"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:abf0d9f45ea5fb95051c8bfe43cb40cda383772f7e5023a83cc481ca2604d74e"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b09719a17a2301178fac4470d54b1680b18a5048b481cb8890e1ef820cb80455"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3d9b48ee6e3967b7901c052b670c7dda6deb812c309439adaffdec55c6d7b78"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:edfe077ab09442d4ef3c52cb1f9dab89bff02f4524afc0acf2d46be17dc479f5"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3debd1150027933210c2fc321527c2299118aa929c2f5a0a80ab6953e3bd1908"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86f63face3a527284f7bb8a9d4f78988e3c06823f7bea2bd6f0e0e9298ca0403"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:24817cb02cbef7cd499f7c9a2735286b4782bd47a5b3516a0e84c50eab44b98e"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c71f16da1ed8949774ef79f4a0260d28b83b3a50c6576f8f4f0288d109777989"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:9cf3126b85822c4e53aa28c7ec9869b924d6fcfb76e77a45c44b83d91afd74f9"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:b3b2316b25644b23b54a6f6401074cebcecd1244c0b8e80111c9a3f1c8e83d65"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:03680bb39035fbcffe828eae9c3f8afc0428c91d38e7d61aa992ef7a59fb120e"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4cc152c5dd831641e995764f9f0b6589519f6f5123258ccaca8c6d34572fefa8"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-win32.whl", hash = "sha256:b8f3307af845803fb0b060ab76cf6dd3a13adc15b6b451f54281d25911eb92df"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:8eaf82f0eccd1505cf39a45a6bd0a8cf1c70dcfc30dba338207a969d91b965c0"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dc45229747b67ffc441b3de2f3ae5e62877a282ea828a5bdb67883c4ee4a8810"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f4a0033ce9a76e391542c182f0d48d084855b5fcba5010f707c8e8c34663d77"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ada214c6fa40f8d800e575de6b91a40d0548139e5dc457d2ebb61470abf50186"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b1121de0e9d6e6ca08289583d7491e7fcb18a439305b34a30b20d8215922d43c"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1063da2c85b95f2d1a430f1c33b55c9c17ffaf5e612e10aeaad641c55a9e2b9d"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70f1d09c0d7748b73290b29219e854b3207aea922f839437870d8cc2168e31cc"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:250c9eb0f4600361dd80d46112213dff2286231d92d3e52af1e5a6083d10cad9"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:750b446b2ffce1739e8578576092179160f6d26bd5e23eb1789c4d64d5af7dc7"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:fc52b79d83a3fe3a360902d3f5d79073a993597d48114c29485e9431092905d8"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:588245972aca710b5b68802c8cad9edaa98589b1b42ad2b53accd6910dad3545"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e39c7eb31e3f5b1f88caff88bcff1b7f8334975b46f6ac6e9fc725d829bc35d4"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-win32.whl", hash = "sha256:abecce40dfebbfa6abf8e324e1860092eeca6f7375c8c4e655a8afb61af58f2c"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:24a91a981f185721542a0b7c92e9054b7ab4fea0508a795846bc5b0abf8118d4"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:67b8cc9574bb518ec76dc8e705d4c39ae78bb96237cb533edac149352c1f39fe"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ac71b2977fb90c35d41c9453116e283fac47bb9096ad917b8819ca8b943abecd"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3ae38d325b512f63f8da31f826e6cb6c367336f95e418137286ba362925c877e"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:542da1178c1c6af8873e143910e2269add130a299c9106eef2594e15dae5e482"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30a85aed0b864ac88309b7d94be09f6046c834ef60762a8833b660139cfbad13"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aae32c93e0f64469f74ccc730a7cb21c7610af3a775157e50bbd38f816536b38"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15b26ddf78d57f1d143bdf32e820fd8935d36abe8a25eb9ec0b5a71c82eb3895"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f5d10bae5d78e4551b7be7a9b29643a95aded9d0f602aa2ba584f0388e7a557"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:249c6470a2b60935bafd1d1d13cd613f8cd8388d53461c67397ee6a0f5dce741"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:c5a74c359b2d47d26cdbbc7845e9662d6b08a1e915eb015d044729e92e7050b7"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:b5bcf60a228acae568e9911f410f9d9e0d43197d030ae5799e20dca8df588287"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:187d18082694a29005ba2944c882344b6748d5be69e3a89bf3cc9d878e548d5a"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:81bf654678e575403736b85ba3a7867e31c2c30a69bc57fe88e3ace52fb17b89"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-win32.whl", hash = "sha256:85a32721ddde63c9df9ebb0d2045b9691d9750cb139c161c80e500d210f5e26e"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:468d2a840567b13a590e67dd276c570f8de00ed767ecc611994c301d0f8c014f"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e0fc42822278451bc13a2e8626cf2218ba570f27856b536e00cfa53099724828"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:09c77f964f351a7369cc343911e0df63e762e42bac24cd7d18525961c81754f4"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:12ebea541c44fdc88ccb794a13fe861cc5e35d64ed689513a5c03d05b53b7c82"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:805dfea4ca10411a5296bcc75638017215a93ffb584c9e344731eef0dcfb026a"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:96c2b49eb6a72c0e4991d62406e365d87067ca14c1a729a870d22354e6f68115"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aaf7b34c5bc56b38c931a54f7952f1ff0ae77a2e82496583b247f7c969eb1479"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:619d1c96099be5823db34fe89e2582b336b5b074a7f47f819d6b3a57ff7bdb86"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a0ac5e7015a5920cfce654c06618ec40c33e12801711da6b4258af59a8eff00a"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:93aa7eef6ee71c629b51ef873991d6911b906d7312c6e8e99790c0f33c576f89"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7966951325782121e67c81299a031f4c115615e68046f79b85856b86ebffc4cd"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:02673e456dc5ab13659f85196c534dc596d4ef260e4d86e856c3b2773ce09843"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:c2af80fb58f0f24b3f3adcb9148e6203fa67dd3f61c4af146ecad033024dde43"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:153e7b6e724761741e0974fc4dcd406d35ba70b92bfe3fedcb497226c93b9da7"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-win32.whl", hash = "sha256:d47ecf253780c90ee181d4d871cd655a789da937454045b17b5798da9393901a"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:d97d85fa63f315a8bdaba2af9a6a686e0eceab77b3089af45133252618e70884"}, - {file = "charset_normalizer-3.3.0-py3-none-any.whl", hash = "sha256:e46cd37076971c1040fc8c41273a8b3e2c624ce4f2be3f5dfcb7a430c1d3acc2"}, + {file = "charset-normalizer-3.3.1.tar.gz", hash = "sha256:d9137a876020661972ca6eec0766d81aef8a5627df628b664b234b73396e727e"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8aee051c89e13565c6bd366813c386939f8e928af93c29fda4af86d25b73d8f8"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:352a88c3df0d1fa886562384b86f9a9e27563d4704ee0e9d56ec6fcd270ea690"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:223b4d54561c01048f657fa6ce41461d5ad8ff128b9678cfe8b2ecd951e3f8a2"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f861d94c2a450b974b86093c6c027888627b8082f1299dfd5a4bae8e2292821"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1171ef1fc5ab4693c5d151ae0fdad7f7349920eabbaca6271f95969fa0756c2d"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28f512b9a33235545fbbdac6a330a510b63be278a50071a336afc1b78781b147"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0e842112fe3f1a4ffcf64b06dc4c61a88441c2f02f373367f7b4c1aa9be2ad5"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f9bc2ce123637a60ebe819f9fccc614da1bcc05798bbbaf2dd4ec91f3e08846"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f194cce575e59ffe442c10a360182a986535fd90b57f7debfaa5c845c409ecc3"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:9a74041ba0bfa9bc9b9bb2cd3238a6ab3b7618e759b41bd15b5f6ad958d17605"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:b578cbe580e3b41ad17b1c428f382c814b32a6ce90f2d8e39e2e635d49e498d1"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:6db3cfb9b4fcecb4390db154e75b49578c87a3b9979b40cdf90d7e4b945656e1"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:debb633f3f7856f95ad957d9b9c781f8e2c6303ef21724ec94bea2ce2fcbd056"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-win32.whl", hash = "sha256:87071618d3d8ec8b186d53cb6e66955ef2a0e4fa63ccd3709c0c90ac5a43520f"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:e372d7dfd154009142631de2d316adad3cc1c36c32a38b16a4751ba78da2a397"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ae4070f741f8d809075ef697877fd350ecf0b7c5837ed68738607ee0a2c572cf"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:58e875eb7016fd014c0eea46c6fa92b87b62c0cb31b9feae25cbbe62c919f54d"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dbd95e300367aa0827496fe75a1766d198d34385a58f97683fe6e07f89ca3e3c"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de0b4caa1c8a21394e8ce971997614a17648f94e1cd0640fbd6b4d14cab13a72"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:985c7965f62f6f32bf432e2681173db41336a9c2611693247069288bcb0c7f8b"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a15c1fe6d26e83fd2e5972425a772cca158eae58b05d4a25a4e474c221053e2d"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae55d592b02c4349525b6ed8f74c692509e5adffa842e582c0f861751701a673"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:be4d9c2770044a59715eb57c1144dedea7c5d5ae80c68fb9959515037cde2008"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:851cf693fb3aaef71031237cd68699dded198657ec1e76a76eb8be58c03a5d1f"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:31bbaba7218904d2eabecf4feec0d07469284e952a27400f23b6628439439fa7"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:871d045d6ccc181fd863a3cd66ee8e395523ebfbc57f85f91f035f50cee8e3d4"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:501adc5eb6cd5f40a6f77fbd90e5ab915c8fd6e8c614af2db5561e16c600d6f3"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f5fb672c396d826ca16a022ac04c9dce74e00a1c344f6ad1a0fdc1ba1f332213"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-win32.whl", hash = "sha256:bb06098d019766ca16fc915ecaa455c1f1cd594204e7f840cd6258237b5079a8"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:8af5a8917b8af42295e86b64903156b4f110a30dca5f3b5aedea123fbd638bff"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:7ae8e5142dcc7a49168f4055255dbcced01dc1714a90a21f87448dc8d90617d1"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5b70bab78accbc672f50e878a5b73ca692f45f5b5e25c8066d748c09405e6a55"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5ceca5876032362ae73b83347be8b5dbd2d1faf3358deb38c9c88776779b2e2f"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d95638ff3613849f473afc33f65c401a89f3b9528d0d213c7037c398a51296"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9edbe6a5bf8b56a4a84533ba2b2f489d0046e755c29616ef8830f9e7d9cf5728"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6a02a3c7950cafaadcd46a226ad9e12fc9744652cc69f9e5534f98b47f3bbcf"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10b8dd31e10f32410751b3430996f9807fc4d1587ca69772e2aa940a82ab571a"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edc0202099ea1d82844316604e17d2b175044f9bcb6b398aab781eba957224bd"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b891a2f68e09c5ef989007fac11476ed33c5c9994449a4e2c3386529d703dc8b"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:71ef3b9be10070360f289aea4838c784f8b851be3ba58cf796262b57775c2f14"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:55602981b2dbf8184c098bc10287e8c245e351cd4fdcad050bd7199d5a8bf514"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:46fb9970aa5eeca547d7aa0de5d4b124a288b42eaefac677bde805013c95725c"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:520b7a142d2524f999447b3a0cf95115df81c4f33003c51a6ab637cbda9d0bf4"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-win32.whl", hash = "sha256:8ec8ef42c6cd5856a7613dcd1eaf21e5573b2185263d87d27c8edcae33b62a61"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:baec8148d6b8bd5cee1ae138ba658c71f5b03e0d69d5907703e3e1df96db5e41"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:63a6f59e2d01310f754c270e4a257426fe5a591dc487f1983b3bbe793cf6bac6"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d6bfc32a68bc0933819cfdfe45f9abc3cae3877e1d90aac7259d57e6e0f85b1"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4f3100d86dcd03c03f7e9c3fdb23d92e32abbca07e7c13ebd7ddfbcb06f5991f"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39b70a6f88eebe239fa775190796d55a33cfb6d36b9ffdd37843f7c4c1b5dc67"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e12f8ee80aa35e746230a2af83e81bd6b52daa92a8afaef4fea4a2ce9b9f4fa"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b6cefa579e1237ce198619b76eaa148b71894fb0d6bcf9024460f9bf30fd228"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:61f1e3fb621f5420523abb71f5771a204b33c21d31e7d9d86881b2cffe92c47c"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4f6e2a839f83a6a76854d12dbebde50e4b1afa63e27761549d006fa53e9aa80e"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:1ec937546cad86d0dce5396748bf392bb7b62a9eeb8c66efac60e947697f0e58"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:82ca51ff0fc5b641a2d4e1cc8c5ff108699b7a56d7f3ad6f6da9dbb6f0145b48"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:633968254f8d421e70f91c6ebe71ed0ab140220469cf87a9857e21c16687c034"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-win32.whl", hash = "sha256:c0c72d34e7de5604df0fde3644cc079feee5e55464967d10b24b1de268deceb9"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:63accd11149c0f9a99e3bc095bbdb5a464862d77a7e309ad5938fbc8721235ae"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5a3580a4fdc4ac05f9e53c57f965e3594b2f99796231380adb2baaab96e22761"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2465aa50c9299d615d757c1c888bc6fef384b7c4aec81c05a0172b4400f98557"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cb7cd68814308aade9d0c93c5bd2ade9f9441666f8ba5aa9c2d4b389cb5e2a45"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91e43805ccafa0a91831f9cd5443aa34528c0c3f2cc48c4cb3d9a7721053874b"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:854cc74367180beb327ab9d00f964f6d91da06450b0855cbbb09187bcdb02de5"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c15070ebf11b8b7fd1bfff7217e9324963c82dbdf6182ff7050519e350e7ad9f"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c4c99f98fc3a1835af8179dcc9013f93594d0670e2fa80c83aa36346ee763d2"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fb765362688821404ad6cf86772fc54993ec11577cd5a92ac44b4c2ba52155b"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:dced27917823df984fe0c80a5c4ad75cf58df0fbfae890bc08004cd3888922a2"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a66bcdf19c1a523e41b8e9d53d0cedbfbac2e93c649a2e9502cb26c014d0980c"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:ecd26be9f112c4f96718290c10f4caea6cc798459a3a76636b817a0ed7874e42"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:3f70fd716855cd3b855316b226a1ac8bdb3caf4f7ea96edcccc6f484217c9597"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:17a866d61259c7de1bdadef418a37755050ddb4b922df8b356503234fff7932c"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-win32.whl", hash = "sha256:548eefad783ed787b38cb6f9a574bd8664468cc76d1538215d510a3cd41406cb"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:45f053a0ece92c734d874861ffe6e3cc92150e32136dd59ab1fb070575189c97"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bc791ec3fd0c4309a753f95bb6c749ef0d8ea3aea91f07ee1cf06b7b02118f2f"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0c8c61fb505c7dad1d251c284e712d4e0372cef3b067f7ddf82a7fa82e1e9a93"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2c092be3885a1b7899cd85ce24acedc1034199d6fca1483fa2c3a35c86e43041"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2000c54c395d9e5e44c99dc7c20a64dc371f777faf8bae4919ad3e99ce5253e"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4cb50a0335382aac15c31b61d8531bc9bb657cfd848b1d7158009472189f3d62"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c30187840d36d0ba2893bc3271a36a517a717f9fd383a98e2697ee890a37c273"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe81b35c33772e56f4b6cf62cf4aedc1762ef7162a31e6ac7fe5e40d0149eb67"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d0bf89afcbcf4d1bb2652f6580e5e55a840fdf87384f6063c4a4f0c95e378656"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:06cf46bdff72f58645434d467bf5228080801298fbba19fe268a01b4534467f5"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:3c66df3f41abee950d6638adc7eac4730a306b022570f71dd0bd6ba53503ab57"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:cd805513198304026bd379d1d516afbf6c3c13f4382134a2c526b8b854da1c2e"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:9505dc359edb6a330efcd2be825fdb73ee3e628d9010597aa1aee5aa63442e97"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:31445f38053476a0c4e6d12b047b08ced81e2c7c712e5a1ad97bc913256f91b2"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-win32.whl", hash = "sha256:bd28b31730f0e982ace8663d108e01199098432a30a4c410d06fe08fdb9e93f4"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:555fe186da0068d3354cdf4bbcbc609b0ecae4d04c921cc13e209eece7720727"}, + {file = "charset_normalizer-3.3.1-py3-none-any.whl", hash = "sha256:800561453acdecedaac137bf09cd719c7a440b6800ec182f077bb8e7025fb708"}, ] [[package]] @@ -311,13 +311,13 @@ toml = ["tomli"] [[package]] name = "dunamai" -version = "1.16.1" +version = "1.18.1" description = "Dynamic version generation" optional = false python-versions = ">=3.5,<4.0" files = [ - {file = "dunamai-1.16.1-py3-none-any.whl", hash = "sha256:b9f169183147f6f1d3a5b3d913ffdd67247d90948006e205cbc499fe98d45554"}, - {file = "dunamai-1.16.1.tar.gz", hash = "sha256:4f3bc2c5b0f9d83fa9c90b943100273bb087167c90a0519ac66e9e2e0d2a8210"}, + {file = "dunamai-1.18.1-py3-none-any.whl", hash = "sha256:ee7b042f7a687fa04fc383258eb93bd819c7bd8aec62e0974f3c69747e5958f2"}, + {file = "dunamai-1.18.1.tar.gz", hash = "sha256:5e9a91e43d16bb56fa8fcddcf92fa31b2e1126e060c3dcc8d094d9b508061f9d"}, ] [package.dependencies] @@ -340,13 +340,13 @@ test = ["pytest (>=6)"] [[package]] name = "gitdb" -version = "4.0.10" +version = "4.0.11" description = "Git Object Database" optional = false python-versions = ">=3.7" files = [ - {file = "gitdb-4.0.10-py3-none-any.whl", hash = "sha256:c286cf298426064079ed96a9e4a9d39e7f3e9bf15ba60701e95f5492f28415c7"}, - {file = "gitdb-4.0.10.tar.gz", hash = "sha256:6eb990b69df4e15bad899ea868dc46572c3f75339735663b81de79b06f17eb9a"}, + {file = "gitdb-4.0.11-py3-none-any.whl", hash = "sha256:81a3407ddd2ee8df444cbacea00e2d038e40150acfa3001696fe0dcf1d3adfa4"}, + {file = "gitdb-4.0.11.tar.gz", hash = "sha256:bf5421126136d6d0af55bc1e7c1af1c397a34f5b7bd79e776cd3e89785c2b04b"}, ] [package.dependencies] @@ -354,13 +354,13 @@ smmap = ">=3.0.1,<6" [[package]] name = "gitpython" -version = "3.1.37" +version = "3.1.40" description = "GitPython is a Python library used to interact with Git repositories" optional = false python-versions = ">=3.7" files = [ - {file = "GitPython-3.1.37-py3-none-any.whl", hash = "sha256:5f4c4187de49616d710a77e98ddf17b4782060a1788df441846bddefbb89ab33"}, - {file = "GitPython-3.1.37.tar.gz", hash = "sha256:f9b9ddc0761c125d5780eab2d64be4873fc6817c2899cbcb34b02344bdc7bc54"}, + {file = "GitPython-3.1.40-py3-none-any.whl", hash = "sha256:cf14627d5a8049ffbf49915732e5eddbe8134c3bdb9d476e6182b676fc573f8a"}, + {file = "GitPython-3.1.40.tar.gz", hash = "sha256:22b126e9ffb671fdd0c129796343a02bf67bf2994b35449ffc9321aa755e18a4"}, ] [package.dependencies] @@ -368,7 +368,7 @@ gitdb = ">=4.0.1,<5" typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\""} [package.extras] -test = ["black", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mypy", "pre-commit", "pytest", "pytest-cov", "pytest-sugar"] +test = ["black", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit", "pytest", "pytest-cov", "pytest-instafail", "pytest-subtests", "pytest-sugar"] [[package]] name = "idna" @@ -578,13 +578,13 @@ hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"] [[package]] name = "pyinstaller-hooks-contrib" -version = "2023.9" +version = "2023.10" description = "Community maintained hooks for PyInstaller" optional = false python-versions = ">=3.7" files = [ - {file = "pyinstaller-hooks-contrib-2023.9.tar.gz", hash = "sha256:76084b5988e3957a9df169d2a935d65500136967e710ddebf57263f1a909cd80"}, - {file = "pyinstaller_hooks_contrib-2023.9-py2.py3-none-any.whl", hash = "sha256:f34f4c6807210025c8073ebe665f422a3aa2ac5f4c7ebf4c2a26cc77bebf63b5"}, + {file = "pyinstaller-hooks-contrib-2023.10.tar.gz", hash = "sha256:4b4a998036abb713774cb26534ca06b7e6e09e4c628196017a10deb11a48747f"}, + {file = "pyinstaller_hooks_contrib-2023.10-py2.py3-none-any.whl", hash = "sha256:6dc1786a8f452941245d5bb85893e2a33632ebdcbc4c23eea41f2ee08281b0c0"}, ] [[package]] @@ -664,7 +664,6 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -672,15 +671,8 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -697,7 +689,6 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -705,7 +696,6 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -945,4 +935,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = ">=3.7,<3.13" -content-hash = "5b143fd05c0d713bd821c03e16cb4c087ddd4f48c17f7dabf479fb6e67ab9cbb" +content-hash = "016a02b0698698558aa590693fdda4a0ce558e247da4ed1eaf7b9315881575f6" diff --git a/pyproject.toml b/pyproject.toml index 5a6ac164..c51a57c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ responses = ">=0.23.1,<0.24.0" [tool.poetry.group.executable.dependencies] pyinstaller = ">=5.13.0,<5.14.0" -dunamai = ">=1.16.1,<1.17.0" +dunamai = ">=1.18.0,<1.19.0" [tool.poetry.group.dev.dependencies] black = ">=23.3.0,<23.4.0" @@ -137,5 +137,5 @@ inline-quotes = "single" "cycode/*.py" = ["BLE001"] [build-system] -requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=0.25.0"] +requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"] build-backend = "poetry_dynamic_versioning.backend" From ebbca2c880420dc60750826dea28799df499a9f9 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Mon, 30 Oct 2023 14:50:57 +0100 Subject: [PATCH 044/257] CM-22319 - Run secrets scanning asynchronously using polling mechanism (#136) --- cycode/cli/code_scanner.py | 12 +- cycode/cyclient/scan_client.py | 45 +++-- cycode/cyclient/scan_config_base.py | 16 ++ tests/cli/test_main.py | 11 +- tests/conftest.py | 2 + tests/cyclient/mocked_responses/__init__.py | 0 .../mocked_responses/data/detections.json | 83 +++++++++ .../cyclient/mocked_responses/scan_client.py | 158 ++++++++++++++++++ tests/cyclient/test_scan_client.py | 67 +------- 9 files changed, 311 insertions(+), 83 deletions(-) create mode 100644 tests/cyclient/mocked_responses/__init__.py create mode 100644 tests/cyclient/mocked_responses/data/detections.json create mode 100644 tests/cyclient/mocked_responses/scan_client.py diff --git a/cycode/cli/code_scanner.py b/cycode/cli/code_scanner.py index 1801d63c..5920fc80 100644 --- a/cycode/cli/code_scanner.py +++ b/cycode/cli/code_scanner.py @@ -546,13 +546,13 @@ def perform_scan( is_commit_range: bool, scan_parameters: dict, ) -> ZippedFileScanResult: - if scan_type in (consts.SCA_SCAN_TYPE, consts.SAST_SCAN_TYPE): - return perform_scan_async(cycode_client, zipped_documents, scan_type, scan_parameters) - if is_commit_range: return cycode_client.commit_range_zipped_file_scan(scan_type, zipped_documents, scan_id) - return cycode_client.zipped_file_scan(scan_type, zipped_documents, scan_id, scan_parameters, is_git_diff) + if scan_type == consts.INFRA_CONFIGURATION_SCAN_TYPE: + return cycode_client.zipped_file_scan(scan_type, zipped_documents, scan_id, scan_parameters, is_git_diff) + + return perform_scan_async(cycode_client, zipped_documents, scan_type, scan_parameters) def perform_scan_async( @@ -1025,6 +1025,10 @@ def _map_detections_per_file(detections: List[dict]) -> List[DetectionsPerFile]: def _get_file_name_from_detection(detection: dict) -> str: if detection['category'] == 'SAST': return detection['detection_details']['file_path'] + if detection['category'] == 'SecretDetection': + file_path = detection['detection_details']['file_path'] + file_name = detection['detection_details']['file_name'] + return os.path.join(file_path, file_name) return detection['detection_details']['file_name'] diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index 5830e9dc..50ee0e79 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -31,14 +31,16 @@ def content_scan(self, scan_type: str, file_name: str, content: str, is_git_diff ) return self.parse_scan_response(response) + def get_zipped_file_scan_url_path(self, scan_type: str) -> str: + return f'{self.scan_config.get_service_name(scan_type)}/{self.SCAN_CONTROLLER_PATH}/zipped-file' + def zipped_file_scan( self, scan_type: str, zip_file: InMemoryZip, scan_id: str, scan_parameters: dict, is_git_diff: bool = False ) -> models.ZippedFileScanResult: - url_path = f'{self.scan_config.get_service_name(scan_type)}/{self.SCAN_CONTROLLER_PATH}/zipped-file' files = {'file': ('multiple_files_scan.zip', zip_file.read())} response = self.scan_cycode_client.post( - url_path=url_path, + url_path=self.get_zipped_file_scan_url_path(scan_type), data={'scan_id': scan_id, 'is_git_diff': is_git_diff, 'scan_parameters': json.dumps(scan_parameters)}, files=files, hide_response_content_log=self._hide_response_log, @@ -46,13 +48,19 @@ def zipped_file_scan( return self.parse_zipped_file_scan_response(response) + def get_zipped_file_scan_async_url_path(self, scan_type: str) -> str: + async_scan_type = self.scan_config.get_async_scan_type(scan_type) + async_entity_type = self.scan_config.get_async_entity_type(scan_type) + + url_prefix = self.scan_config.get_scans_prefix() + return f'{url_prefix}/{self.SCAN_CONTROLLER_PATH}/{async_scan_type}/{async_entity_type}' + def zipped_file_scan_async( self, zip_file: InMemoryZip, scan_type: str, scan_parameters: dict, is_git_diff: bool = False ) -> models.ScanInitializationResponse: - url_path = f'{self.scan_config.get_scans_prefix()}/{self.SCAN_CONTROLLER_PATH}/{scan_type}/repository' files = {'file': ('multiple_files_scan.zip', zip_file.read())} response = self.scan_cycode_client.post( - url_path=url_path, + url_path=self.get_zipped_file_scan_async_url_path(scan_type), data={'is_git_diff': is_git_diff, 'scan_parameters': json.dumps(scan_parameters)}, files=files, ) @@ -80,13 +88,17 @@ def multiple_zipped_file_scan_async( ) return models.ScanInitializationResponseSchema().load(response.json()) + def get_scan_details_path(self, scan_id: str) -> str: + return f'{self.scan_config.get_scans_prefix()}/{self.SCAN_CONTROLLER_PATH}/{scan_id}' + def get_scan_details(self, scan_id: str) -> models.ScanDetailsResponse: - url_path = f'{self.scan_config.get_scans_prefix()}/{self.SCAN_CONTROLLER_PATH}/{scan_id}' - response = self.scan_cycode_client.get(url_path=url_path) + response = self.scan_cycode_client.get(url_path=self.get_scan_details_path(scan_id)) return models.ScanDetailsResponseSchema().load(response.json()) + def get_scan_detections_path(self) -> str: + return f'{self.scan_config.get_detections_prefix()}/{self.DETECTIONS_SERVICE_CONTROLLER_PATH}' + def get_scan_detections(self, scan_id: str) -> List[dict]: - url_path = f'{self.scan_config.get_detections_prefix()}/{self.DETECTIONS_SERVICE_CONTROLLER_PATH}' params = {'scan_id': scan_id} page_size = 200 @@ -100,7 +112,9 @@ def get_scan_detections(self, scan_id: str) -> List[dict]: params['page_number'] = page_number response = self.scan_cycode_client.get( - url_path=url_path, params=params, hide_response_content_log=self._hide_response_log + url_path=self.get_scan_detections_path(), + params=params, + hide_response_content_log=self._hide_response_log, ).json() detections.extend(response) @@ -109,9 +123,13 @@ def get_scan_detections(self, scan_id: str) -> List[dict]: return detections + def get_get_scan_detections_count_path(self) -> str: + return f'{self.scan_config.get_detections_prefix()}/{self.DETECTIONS_SERVICE_CONTROLLER_PATH}/count' + def get_scan_detections_count(self, scan_id: str) -> int: - url_path = f'{self.scan_config.get_detections_prefix()}/{self.DETECTIONS_SERVICE_CONTROLLER_PATH}/count' - response = self.scan_cycode_client.get(url_path=url_path, params={'scan_id': scan_id}) + response = self.scan_cycode_client.get( + url_path=self.get_get_scan_detections_count_path(), params={'scan_id': scan_id} + ) return response.json().get('count', 0) def commit_range_zipped_file_scan( @@ -126,9 +144,11 @@ def commit_range_zipped_file_scan( ) return self.parse_zipped_file_scan_response(response) + def get_report_scan_status_path(self, scan_type: str, scan_id: str) -> str: + return f'{self.scan_config.get_service_name(scan_type)}/{self.SCAN_CONTROLLER_PATH}/{scan_id}/status' + def report_scan_status(self, scan_type: str, scan_id: str, scan_status: dict) -> None: - url_path = f'{self.scan_config.get_service_name(scan_type)}/{self.SCAN_CONTROLLER_PATH}/{scan_id}/status' - self.scan_cycode_client.post(url_path=url_path, body=scan_status) + self.scan_cycode_client.post(url_path=self.get_report_scan_status_path(scan_type, scan_id), body=scan_status) @staticmethod def parse_scan_response(response: Response) -> models.ScanResult: @@ -140,6 +160,7 @@ def parse_zipped_file_scan_response(response: Response) -> models.ZippedFileScan @staticmethod def get_service_name(scan_type: str) -> Optional[str]: + # TODO(MarshalX): get_service_name should be removed from ScanClient? Because it exists in ScanConfig if scan_type == 'secret': return 'secret' if scan_type == 'iac': diff --git a/cycode/cyclient/scan_config_base.py b/cycode/cyclient/scan_config_base.py index 976008bb..0fffa1a2 100644 --- a/cycode/cyclient/scan_config_base.py +++ b/cycode/cyclient/scan_config_base.py @@ -6,6 +6,22 @@ class ScanConfigBase(ABC): def get_service_name(self, scan_type: str) -> str: ... + @staticmethod + def get_async_scan_type(scan_type: str) -> str: + if scan_type == 'secret': + return 'Secrets' + if scan_type == 'iac': + return 'InfraConfiguration' + + return scan_type.upper() + + @staticmethod + def get_async_entity_type(scan_type: str) -> str: + if scan_type == 'secret': + return 'zippedfile' + + return 'repository' + @abstractmethod def get_scans_prefix(self) -> str: ... diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py index 8c3c6246..aa196c0e 100644 --- a/tests/cli/test_main.py +++ b/tests/cli/test_main.py @@ -1,13 +1,14 @@ import json from typing import TYPE_CHECKING +from uuid import UUID import pytest import responses from click.testing import CliRunner from cycode.cli.main import main_cli -from tests.conftest import CLI_ENV_VARS, TEST_FILES_PATH -from tests.cyclient.test_scan_client import get_zipped_file_scan_response, get_zipped_file_scan_url +from tests.conftest import CLI_ENV_VARS, TEST_FILES_PATH, ZIP_CONTENT_PATH +from tests.cyclient.mocked_responses.scan_client import mock_scan_async_responses _PATH_TO_SCAN = TEST_FILES_PATH.joinpath('zip_content').absolute() @@ -27,12 +28,10 @@ def _is_json(plain: str) -> bool: @pytest.mark.parametrize('output', ['text', 'json']) def test_passing_output_option(output: str, scan_client: 'ScanClient', api_token_response: responses.Response) -> None: scan_type = 'secret' + scan_id = UUID('12345678-418f-47ee-abb0-012345678901') - responses.add(get_zipped_file_scan_response(get_zipped_file_scan_url(scan_type, scan_client))) responses.add(api_token_response) - # Scan report is not mocked. - # This raises connection error on the attempt to report scan. - # It doesn't perform real request + mock_scan_async_responses(responses, scan_type, scan_client, scan_id, ZIP_CONTENT_PATH) args = ['--output', output, 'scan', '--soft-fail', 'path', str(_PATH_TO_SCAN)] result = CliRunner().invoke(main_cli, args, env=CLI_ENV_VARS) diff --git a/tests/conftest.py b/tests/conftest.py index fdb02ec2..dc1a84fe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,6 +15,8 @@ CLI_ENV_VARS = {'CYCODE_CLIENT_ID': _CLIENT_ID, 'CYCODE_CLIENT_SECRET': _CLIENT_SECRET} TEST_FILES_PATH = Path(__file__).parent.joinpath('test_files').absolute() +MOCKED_RESPONSES_PATH = Path(__file__).parent.joinpath('cyclient/mocked_responses/data').absolute() +ZIP_CONTENT_PATH = TEST_FILES_PATH.joinpath('zip_content').absolute() @pytest.fixture(scope='session') diff --git a/tests/cyclient/mocked_responses/__init__.py b/tests/cyclient/mocked_responses/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cyclient/mocked_responses/data/detections.json b/tests/cyclient/mocked_responses/data/detections.json new file mode 100644 index 00000000..89196489 --- /dev/null +++ b/tests/cyclient/mocked_responses/data/detections.json @@ -0,0 +1,83 @@ +[ + { + "source_policy_name": "Secrets detection", + "source_policy_type": "SensitiveContent", + "source_entity_name": null, + "source_entity_id": null, + "detection_type_id": "7dff932a-418f-47ee-abb0-703e0f6592cd", + "root_id": null, + "status": "Open", + "status_updated_at": null, + "status_reason": null, + "status_change_message": null, + "source_entity_type": "Audit", + "detection_details": { + "organization_name": null, + "organization_id": "", + "sha512": "6e6c867188c04340d9ecfa1b7e56a356e605f2a70fbda865f11b4a57eb07e634", + "provider": "CycodeCli", + "concrete_provider": "CycodeCli", + "length": 55, + "start_position": 19, + "line": 0, + "commit_id": null, + "member_id": "", + "member_name": "", + "member_email": "", + "author_name": "", + "author_email": "", + "branch_name": "", + "committer_name": "", + "committed_at": "0001-01-01T00:00:00+00:00", + "file_path": "%FILEPATH%", + "file_name": "secrets.py", + "file_extension": ".py", + "url": null, + "should_resolve_upon_branch_deletion": false, + "position_in_line": 19, + "repository_name": null, + "repository_id": null, + "old_detection_id": "f35c42f99d3712d4593d5ee16a9ceb36ca9fb20b33e68edd0e00847e6a02a7b6" + }, + "severity": "Medium", + "remediable": false, + "correlation_message": "Secret of type 'Slack Token' was found in filename 'secrets.py' within '' repository", + "provider": "CycodeCli", + "scan_id": "%SCAN_ID%", + "assignee_id": null, + "type": "slack-token", + "is_hidden": false, + "tags": [], + "detection_rule_id": "26ab3395-2522-4061-a50a-c69c2d622ca1", + "classification": null, + "priority": 0, + "metadata": null, + "labels": [], + "detection_id": "f35c42f99d3712d4593d5ee16a9ceb36ca9fb20b33e68edd0e00847e6a02a7b6", + "internal_note": null, + "sdlc_stages": [ + "Code", + "Container Registry", + "Productivity Tools", + "Cloud", + "Build" + ], + "policy_labels": [], + "category": "SecretDetection", + "sub_category": "SensitiveContent", + "sub_category_v2": "Messaging Systems", + "policy_tags": [], + "remediations": [], + "instruction_details": { + "instruction_name_to_single_id_map": null, + "instruction_name_to_multiple_ids_map": null, + "instruction_tags": null + }, + "external_detection_references": [], + "project_ids": [], + "tenant_id": "123456-663f-4e27-9170-e559c2379292", + "id": "123456-895a-4830-b6c1-b948e99b71a4", + "created_date": "2023-10-25T11:07:14.7516793+00:00", + "updated_date": "2023-10-25T11:07:14.7616063+00:00" + } +] \ No newline at end of file diff --git a/tests/cyclient/mocked_responses/scan_client.py b/tests/cyclient/mocked_responses/scan_client.py new file mode 100644 index 00000000..8518e2b9 --- /dev/null +++ b/tests/cyclient/mocked_responses/scan_client.py @@ -0,0 +1,158 @@ +import json +from pathlib import Path +from typing import Optional +from uuid import UUID, uuid4 + +import responses + +from cycode.cyclient.scan_client import ScanClient +from tests.conftest import MOCKED_RESPONSES_PATH + + +def get_zipped_file_scan_url(scan_type: str, scan_client: ScanClient) -> str: + api_url = scan_client.scan_cycode_client.api_url + service_url = scan_client.get_zipped_file_scan_url_path(scan_type) + return f'{api_url}/{service_url}' + + +def get_zipped_file_scan_response( + url: str, zip_content_path: Path, scan_id: Optional[UUID] = None +) -> responses.Response: + if not scan_id: + scan_id = uuid4() + + json_response = { + 'did_detect': True, + 'scan_id': str(scan_id), # not always as expected due to _get_scan_id and passing scan_id to cxt of CLI + 'detections_per_file': [ + { + 'file_name': str(zip_content_path.joinpath('secrets.py')), + 'commit_id': None, + 'detections': [ + { + 'detection_type_id': '12345678-418f-47ee-abb0-012345678901', + 'detection_rule_id': '12345678-aea1-4304-a6e9-012345678901', + 'message': "Secret of type 'Slack Token' was found in filename 'secrets.py'", + 'type': 'slack-token', + 'is_research': False, + 'detection_details': { + 'sha512': 'sha hash', + 'length': 55, + 'start_position': 19, + 'line': 0, + 'committed_at': '0001-01-01T00:00:00+00:00', + 'file_path': str(zip_content_path), + 'file_name': 'secrets.py', + 'file_extension': '.py', + 'should_resolve_upon_branch_deletion': False, + }, + } + ], + } + ], + 'report_url': None, + } + + return responses.Response(method=responses.POST, url=url, json=json_response, status=200) + + +def get_zipped_file_scan_async_url(scan_type: str, scan_client: ScanClient) -> str: + api_url = scan_client.scan_cycode_client.api_url + service_url = scan_client.get_zipped_file_scan_async_url_path(scan_type) + return f'{api_url}/{service_url}' + + +def get_zipped_file_scan_async_response(url: str, scan_id: Optional[UUID] = None) -> responses.Response: + if not scan_id: + scan_id = uuid4() + + json_response = { + 'scan_id': str(scan_id), # not always as expected due to _get_scan_id and passing scan_id to cxt of CLI + } + + return responses.Response(method=responses.POST, url=url, json=json_response, status=200) + + +def get_scan_details_url(scan_id: Optional[UUID], scan_client: ScanClient) -> str: + api_url = scan_client.scan_cycode_client.api_url + service_url = scan_client.get_scan_details_path(str(scan_id)) + return f'{api_url}/{service_url}' + + +def get_scan_details_response(url: str, scan_id: Optional[UUID] = None) -> responses.Response: + if not scan_id: + scan_id = uuid4() + + json_response = { + 'id': str(scan_id), + 'scan_type': 'Secrets', + 'metadata': f'Path: {scan_id}, Folder: scans, Size: 465', + 'entity_type': 'ZippedFile', + 'entity_id': 'Repository', + 'parent_entity_id': 'Organization', + 'organization_id': 'Organization', + 'scm_provider': 'CycodeCli', + 'started_at': '2023-10-25T10:02:23.048282+00:00', + 'finished_at': '2023-10-25T10:02:26.867082+00:00', + 'scan_status': 'Completed', # mark as completed to avoid mocking repeated requests + 'message': None, + 'results_count': 1, + 'scan_update_at': '2023-10-25T10:02:26.867216+00:00', + 'duration': {'days': 0, 'hours': 0, 'minutes': 0, 'seconds': 3, 'milliseconds': 818}, + 'is_hidden': False, + 'is_initial_scan': False, + 'detection_messages': [], + } + + return responses.Response(method=responses.GET, url=url, json=json_response, status=200) + + +def get_scan_detections_count_url(scan_client: ScanClient) -> str: + api_url = scan_client.scan_cycode_client.api_url + service_url = scan_client.get_get_scan_detections_count_path() + return f'{api_url}/{service_url}' + + +def get_scan_detections_count_response(url: str) -> responses.Response: + json_response = {'count': 1} + + return responses.Response(method=responses.GET, url=url, json=json_response, status=200) + + +def get_scan_detections_url(scan_client: ScanClient) -> str: + api_url = scan_client.scan_cycode_client.api_url + service_url = scan_client.get_scan_detections_path() + return f'{api_url}/{service_url}' + + +def get_scan_detections_response(url: str, scan_id: UUID, zip_content_path: Path) -> responses.Response: + with open(MOCKED_RESPONSES_PATH.joinpath('detections.json'), encoding='UTF-8') as f: + content = f.read() + content = content.replace('%FILEPATH%', str(zip_content_path.absolute().as_posix())) + content = content.replace('%SCAN_ID%', str(scan_id)) + + json_response = json.loads(content) + + return responses.Response(method=responses.GET, url=url, json=json_response, status=200) + + +def get_report_scan_status_url(scan_type: str, scan_id: UUID, scan_client: ScanClient) -> str: + api_url = scan_client.scan_cycode_client.api_url + service_url = scan_client.get_report_scan_status_path(scan_type, str(scan_id)) + return f'{api_url}/{service_url}' + + +def get_report_scan_status_response(url: str) -> responses.Response: + return responses.Response(method=responses.POST, url=url, status=200) + + +def mock_scan_async_responses( + responses_module: responses, scan_type: str, scan_client: ScanClient, scan_id: UUID, zip_content_path: Path +) -> None: + responses_module.add( + get_zipped_file_scan_async_response(get_zipped_file_scan_async_url(scan_type, scan_client), scan_id) + ) + responses_module.add(get_scan_details_response(get_scan_details_url(scan_id, scan_client), scan_id)) + responses_module.add(get_scan_detections_count_response(get_scan_detections_count_url(scan_client))) + responses_module.add(get_scan_detections_response(get_scan_detections_url(scan_client), scan_id, zip_content_path)) + responses_module.add(get_report_scan_status_response(get_report_scan_status_url(scan_type, scan_id, scan_client))) diff --git a/tests/cyclient/test_scan_client.py b/tests/cyclient/test_scan_client.py index 2ca374b2..1f8ed462 100644 --- a/tests/cyclient/test_scan_client.py +++ b/tests/cyclient/test_scan_client.py @@ -1,6 +1,6 @@ import os -from typing import List, Optional, Tuple -from uuid import UUID, uuid4 +from typing import List, Tuple +from uuid import uuid4 import pytest import requests @@ -14,9 +14,8 @@ from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip from cycode.cli.models import Document from cycode.cyclient.scan_client import ScanClient -from tests.conftest import TEST_FILES_PATH - -_ZIP_CONTENT_PATH = TEST_FILES_PATH.joinpath('zip_content').absolute() +from tests.conftest import ZIP_CONTENT_PATH +from tests.cyclient.mocked_responses.scan_client import get_zipped_file_scan_response, get_zipped_file_scan_url def zip_scan_resources(scan_type: str, scan_client: ScanClient) -> Tuple[str, InMemoryZip]: @@ -26,17 +25,10 @@ def zip_scan_resources(scan_type: str, scan_client: ScanClient) -> Tuple[str, In return url, zip_file -def get_zipped_file_scan_url(scan_type: str, scan_client: ScanClient) -> str: - api_url = scan_client.scan_cycode_client.api_url - # TODO(MarshalX): create method in the scan client to build this url - service_url = f'{api_url}/{scan_client.scan_config.get_service_name(scan_type)}' - return f'{service_url}/{scan_client.SCAN_CONTROLLER_PATH}/zipped-file' - - def get_test_zip_file(scan_type: str) -> InMemoryZip: # TODO(MarshalX): refactor scan_disk_files in code_scanner.py to reuse method here instead of this test_documents: List[Document] = [] - for root, _, files in os.walk(_ZIP_CONTENT_PATH): + for root, _, files in os.walk(ZIP_CONTENT_PATH): for name in files: path = os.path.join(root, name) with open(path, 'r', encoding='UTF-8') as f: @@ -45,53 +37,6 @@ def get_test_zip_file(scan_type: str) -> InMemoryZip: return zip_documents(scan_type, test_documents) -def get_zipped_file_scan_response(url: str, scan_id: Optional[UUID] = None) -> responses.Response: - if not scan_id: - scan_id = uuid4() - - json_response = { - 'did_detect': True, - 'scan_id': str(scan_id), # not always as expected due to _get_scan_id and passing scan_id to cxt of CLI - 'detections_per_file': [ - { - 'file_name': str(_ZIP_CONTENT_PATH.joinpath('secrets.py')), - 'commit_id': None, - 'detections': [ - { - 'detection_type_id': '12345678-418f-47ee-abb0-012345678901', - 'detection_rule_id': '12345678-aea1-4304-a6e9-012345678901', - 'message': "Secret of type 'Slack Token' was found in filename 'secrets.py'", - 'type': 'slack-token', - 'is_research': False, - 'detection_details': { - 'sha512': 'sha hash', - 'length': 55, - 'start_position': 19, - 'line': 0, - 'committed_at': '0001-01-01T00:00:00+00:00', - 'file_path': str(_ZIP_CONTENT_PATH), - 'file_name': 'secrets.py', - 'file_extension': '.py', - 'should_resolve_upon_branch_deletion': False, - }, - } - ], - } - ], - 'report_url': None, - } - - return responses.Response(method=responses.POST, url=url, json=json_response, status=200) - - -def test_get_service_name(scan_client: ScanClient) -> None: - # TODO(MarshalX): get_service_name should be removed from ScanClient? Because it exists in ScanConfig - assert scan_client.get_service_name('secret') == 'secret' - assert scan_client.get_service_name('iac') == 'iac' - assert scan_client.get_service_name('sca') == 'scans' - assert scan_client.get_service_name('sast') == 'scans' - - @pytest.mark.parametrize('scan_type', config['scans']['supported_scans']) @responses.activate def test_zipped_file_scan(scan_type: str, scan_client: ScanClient, api_token_response: responses.Response) -> None: @@ -99,7 +44,7 @@ def test_zipped_file_scan(scan_type: str, scan_client: ScanClient, api_token_res expected_scan_id = uuid4() responses.add(api_token_response) # mock token based client - responses.add(get_zipped_file_scan_response(url, expected_scan_id)) + responses.add(get_zipped_file_scan_response(url, ZIP_CONTENT_PATH, expected_scan_id)) zipped_file_scan_response = scan_client.zipped_file_scan( scan_type, zip_file, scan_id=str(expected_scan_id), scan_parameters={} From aa72f50462752281be12eb4838f4a2aad0e018fb Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Mon, 6 Nov 2023 13:22:06 +0100 Subject: [PATCH 045/257] CM-28230 - Update GitHub organization name (#176) --- LICENCE | 2 +- README.md | 25 ++++--- poetry.lock | 182 ++++++++++++++++++++++++------------------------- pyproject.toml | 2 +- 4 files changed, 105 insertions(+), 106 deletions(-) diff --git a/LICENCE b/LICENCE index 404e3d08..8d72be9a 100644 --- a/LICENCE +++ b/LICENCE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 cycodehq-public +Copyright (c) 2022 Cycode Ltd. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index ffbaf350..e1efe9d9 100644 --- a/README.md +++ b/README.md @@ -91,24 +91,24 @@ To install the Cycode CLI application on your local machine, perform the followi 2. A browser window will appear, asking you to log into Cycode (as seen below): -![Cycode login](https://raw.githubusercontent.com/cycodehq-public/cycode-cli/main/images/cycode_login.png) +![Cycode login](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/cycode_login.png) 3. Enter your login credentials on this page and log in. 4. You will eventually be taken to the page below, where you'll be asked to choose the business group you want to authorize Cycode with (if applicable): -![authorize CLI](https://raw.githubusercontent.com/cycodehq-public/cycode-cli/main/images/authorize_cli.png) +![authorize CLI](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/authorize_cli.png) > :memo: **Note**
> This will be the default method for authenticating with the Cycode CLI. 5. Click the **Allow** button to authorize the Cycode CLI on the selected business group. -![allow CLI](https://raw.githubusercontent.com/cycodehq-public/cycode-cli/main/images/allow_cli.png) +![allow CLI](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/allow_cli.png) 6. Once completed, you'll see the following screen, if it was selected successfully: -![successfully auth](https://raw.githubusercontent.com/cycodehq-public/cycode-cli/main/images/successfully_auth.png) +![successfully auth](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/successfully_auth.png) 7. In the terminal/command line screen, you will see the following when exiting the browser window: @@ -177,19 +177,19 @@ export CYCODE_CLIENT_SECRET={your Cycode Secret Key} 1. From the Control Panel, navigate to the System menu: -![](https://raw.githubusercontent.com/cycodehq-public/cycode-cli/main/images/image1.png) +![](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/image1.png) 2. Next, click Advanced system settings: -![](https://raw.githubusercontent.com/cycodehq-public/cycode-cli/main/images/image2.png) +![](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/image2.png) 3. In the System Properties window that opens, click the Environment Variables button: -![](https://raw.githubusercontent.com/cycodehq-public/cycode-cli/main/images/image3.png) +![](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/image3.png) 4. Create `CYCODE_CLIENT_ID` and `CYCODE_CLIENT_SECRET` variables with values matching your ID and Secret Key, respectively: -![](https://raw.githubusercontent.com/cycodehq-public/cycode-cli/main/images/image4.png) +![](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/image4.png) 5. Insert the cycode.exe into the path to complete the installation. @@ -209,11 +209,10 @@ Perform the following steps to install the pre-commit hook: ```yaml repos: - - repo: https://github.com/cycodehq-public/cycode-cli - rev: stable + - repo: https://github.com/cycodehq/cycode-cli + rev: v1.4.0 hooks: - id: cycode - language_version: python3 stages: - commit ``` @@ -383,7 +382,7 @@ Report URL: https://app.cycode.com/on-demand-scans/617ecc3d-9ff2-493e-8be8-2c1fe The report page will look something like below: -![](https://raw.githubusercontent.com/cycodehq-public/cycode-cli/main/images/scan_details.png) +![](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/scan_details.png) ### Package Vulnerabilities Option @@ -758,7 +757,7 @@ To create an SBOM report for a repository URI:\ `cycode report sbom --format --include-vulnerabilities --include-dev-dependencies --output-file
repository_url ` For example:\ -`cycode report sbom --format spdx-2.3 --include-vulnerabilities --include-dev-dependencies repository_url https://github.com/cycodehq-public/cycode-cli.git` +`cycode report sbom --format spdx-2.3 --include-vulnerabilities --include-dev-dependencies repository_url https://github.com/cycodehq/cycode-cli.git` ### Local Project diff --git a/poetry.lock b/poetry.lock index 04f7b712..46d5ffcd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -114,101 +114,101 @@ files = [ [[package]] name = "charset-normalizer" -version = "3.3.1" +version = "3.3.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7.0" files = [ - {file = "charset-normalizer-3.3.1.tar.gz", hash = "sha256:d9137a876020661972ca6eec0766d81aef8a5627df628b664b234b73396e727e"}, - {file = "charset_normalizer-3.3.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8aee051c89e13565c6bd366813c386939f8e928af93c29fda4af86d25b73d8f8"}, - {file = "charset_normalizer-3.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:352a88c3df0d1fa886562384b86f9a9e27563d4704ee0e9d56ec6fcd270ea690"}, - {file = "charset_normalizer-3.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:223b4d54561c01048f657fa6ce41461d5ad8ff128b9678cfe8b2ecd951e3f8a2"}, - {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f861d94c2a450b974b86093c6c027888627b8082f1299dfd5a4bae8e2292821"}, - {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1171ef1fc5ab4693c5d151ae0fdad7f7349920eabbaca6271f95969fa0756c2d"}, - {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28f512b9a33235545fbbdac6a330a510b63be278a50071a336afc1b78781b147"}, - {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0e842112fe3f1a4ffcf64b06dc4c61a88441c2f02f373367f7b4c1aa9be2ad5"}, - {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f9bc2ce123637a60ebe819f9fccc614da1bcc05798bbbaf2dd4ec91f3e08846"}, - {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f194cce575e59ffe442c10a360182a986535fd90b57f7debfaa5c845c409ecc3"}, - {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:9a74041ba0bfa9bc9b9bb2cd3238a6ab3b7618e759b41bd15b5f6ad958d17605"}, - {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:b578cbe580e3b41ad17b1c428f382c814b32a6ce90f2d8e39e2e635d49e498d1"}, - {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:6db3cfb9b4fcecb4390db154e75b49578c87a3b9979b40cdf90d7e4b945656e1"}, - {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:debb633f3f7856f95ad957d9b9c781f8e2c6303ef21724ec94bea2ce2fcbd056"}, - {file = "charset_normalizer-3.3.1-cp310-cp310-win32.whl", hash = "sha256:87071618d3d8ec8b186d53cb6e66955ef2a0e4fa63ccd3709c0c90ac5a43520f"}, - {file = "charset_normalizer-3.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:e372d7dfd154009142631de2d316adad3cc1c36c32a38b16a4751ba78da2a397"}, - {file = "charset_normalizer-3.3.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ae4070f741f8d809075ef697877fd350ecf0b7c5837ed68738607ee0a2c572cf"}, - {file = "charset_normalizer-3.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:58e875eb7016fd014c0eea46c6fa92b87b62c0cb31b9feae25cbbe62c919f54d"}, - {file = "charset_normalizer-3.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dbd95e300367aa0827496fe75a1766d198d34385a58f97683fe6e07f89ca3e3c"}, - {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de0b4caa1c8a21394e8ce971997614a17648f94e1cd0640fbd6b4d14cab13a72"}, - {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:985c7965f62f6f32bf432e2681173db41336a9c2611693247069288bcb0c7f8b"}, - {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a15c1fe6d26e83fd2e5972425a772cca158eae58b05d4a25a4e474c221053e2d"}, - {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae55d592b02c4349525b6ed8f74c692509e5adffa842e582c0f861751701a673"}, - {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:be4d9c2770044a59715eb57c1144dedea7c5d5ae80c68fb9959515037cde2008"}, - {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:851cf693fb3aaef71031237cd68699dded198657ec1e76a76eb8be58c03a5d1f"}, - {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:31bbaba7218904d2eabecf4feec0d07469284e952a27400f23b6628439439fa7"}, - {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:871d045d6ccc181fd863a3cd66ee8e395523ebfbc57f85f91f035f50cee8e3d4"}, - {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:501adc5eb6cd5f40a6f77fbd90e5ab915c8fd6e8c614af2db5561e16c600d6f3"}, - {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f5fb672c396d826ca16a022ac04c9dce74e00a1c344f6ad1a0fdc1ba1f332213"}, - {file = "charset_normalizer-3.3.1-cp311-cp311-win32.whl", hash = "sha256:bb06098d019766ca16fc915ecaa455c1f1cd594204e7f840cd6258237b5079a8"}, - {file = "charset_normalizer-3.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:8af5a8917b8af42295e86b64903156b4f110a30dca5f3b5aedea123fbd638bff"}, - {file = "charset_normalizer-3.3.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:7ae8e5142dcc7a49168f4055255dbcced01dc1714a90a21f87448dc8d90617d1"}, - {file = "charset_normalizer-3.3.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5b70bab78accbc672f50e878a5b73ca692f45f5b5e25c8066d748c09405e6a55"}, - {file = "charset_normalizer-3.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5ceca5876032362ae73b83347be8b5dbd2d1faf3358deb38c9c88776779b2e2f"}, - {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d95638ff3613849f473afc33f65c401a89f3b9528d0d213c7037c398a51296"}, - {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9edbe6a5bf8b56a4a84533ba2b2f489d0046e755c29616ef8830f9e7d9cf5728"}, - {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6a02a3c7950cafaadcd46a226ad9e12fc9744652cc69f9e5534f98b47f3bbcf"}, - {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10b8dd31e10f32410751b3430996f9807fc4d1587ca69772e2aa940a82ab571a"}, - {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edc0202099ea1d82844316604e17d2b175044f9bcb6b398aab781eba957224bd"}, - {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b891a2f68e09c5ef989007fac11476ed33c5c9994449a4e2c3386529d703dc8b"}, - {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:71ef3b9be10070360f289aea4838c784f8b851be3ba58cf796262b57775c2f14"}, - {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:55602981b2dbf8184c098bc10287e8c245e351cd4fdcad050bd7199d5a8bf514"}, - {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:46fb9970aa5eeca547d7aa0de5d4b124a288b42eaefac677bde805013c95725c"}, - {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:520b7a142d2524f999447b3a0cf95115df81c4f33003c51a6ab637cbda9d0bf4"}, - {file = "charset_normalizer-3.3.1-cp312-cp312-win32.whl", hash = "sha256:8ec8ef42c6cd5856a7613dcd1eaf21e5573b2185263d87d27c8edcae33b62a61"}, - {file = "charset_normalizer-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:baec8148d6b8bd5cee1ae138ba658c71f5b03e0d69d5907703e3e1df96db5e41"}, - {file = "charset_normalizer-3.3.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:63a6f59e2d01310f754c270e4a257426fe5a591dc487f1983b3bbe793cf6bac6"}, - {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d6bfc32a68bc0933819cfdfe45f9abc3cae3877e1d90aac7259d57e6e0f85b1"}, - {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4f3100d86dcd03c03f7e9c3fdb23d92e32abbca07e7c13ebd7ddfbcb06f5991f"}, - {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39b70a6f88eebe239fa775190796d55a33cfb6d36b9ffdd37843f7c4c1b5dc67"}, - {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e12f8ee80aa35e746230a2af83e81bd6b52daa92a8afaef4fea4a2ce9b9f4fa"}, - {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b6cefa579e1237ce198619b76eaa148b71894fb0d6bcf9024460f9bf30fd228"}, - {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:61f1e3fb621f5420523abb71f5771a204b33c21d31e7d9d86881b2cffe92c47c"}, - {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4f6e2a839f83a6a76854d12dbebde50e4b1afa63e27761549d006fa53e9aa80e"}, - {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:1ec937546cad86d0dce5396748bf392bb7b62a9eeb8c66efac60e947697f0e58"}, - {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:82ca51ff0fc5b641a2d4e1cc8c5ff108699b7a56d7f3ad6f6da9dbb6f0145b48"}, - {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:633968254f8d421e70f91c6ebe71ed0ab140220469cf87a9857e21c16687c034"}, - {file = "charset_normalizer-3.3.1-cp37-cp37m-win32.whl", hash = "sha256:c0c72d34e7de5604df0fde3644cc079feee5e55464967d10b24b1de268deceb9"}, - {file = "charset_normalizer-3.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:63accd11149c0f9a99e3bc095bbdb5a464862d77a7e309ad5938fbc8721235ae"}, - {file = "charset_normalizer-3.3.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5a3580a4fdc4ac05f9e53c57f965e3594b2f99796231380adb2baaab96e22761"}, - {file = "charset_normalizer-3.3.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2465aa50c9299d615d757c1c888bc6fef384b7c4aec81c05a0172b4400f98557"}, - {file = "charset_normalizer-3.3.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cb7cd68814308aade9d0c93c5bd2ade9f9441666f8ba5aa9c2d4b389cb5e2a45"}, - {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91e43805ccafa0a91831f9cd5443aa34528c0c3f2cc48c4cb3d9a7721053874b"}, - {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:854cc74367180beb327ab9d00f964f6d91da06450b0855cbbb09187bcdb02de5"}, - {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c15070ebf11b8b7fd1bfff7217e9324963c82dbdf6182ff7050519e350e7ad9f"}, - {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c4c99f98fc3a1835af8179dcc9013f93594d0670e2fa80c83aa36346ee763d2"}, - {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fb765362688821404ad6cf86772fc54993ec11577cd5a92ac44b4c2ba52155b"}, - {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:dced27917823df984fe0c80a5c4ad75cf58df0fbfae890bc08004cd3888922a2"}, - {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a66bcdf19c1a523e41b8e9d53d0cedbfbac2e93c649a2e9502cb26c014d0980c"}, - {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:ecd26be9f112c4f96718290c10f4caea6cc798459a3a76636b817a0ed7874e42"}, - {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:3f70fd716855cd3b855316b226a1ac8bdb3caf4f7ea96edcccc6f484217c9597"}, - {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:17a866d61259c7de1bdadef418a37755050ddb4b922df8b356503234fff7932c"}, - {file = "charset_normalizer-3.3.1-cp38-cp38-win32.whl", hash = "sha256:548eefad783ed787b38cb6f9a574bd8664468cc76d1538215d510a3cd41406cb"}, - {file = "charset_normalizer-3.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:45f053a0ece92c734d874861ffe6e3cc92150e32136dd59ab1fb070575189c97"}, - {file = "charset_normalizer-3.3.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bc791ec3fd0c4309a753f95bb6c749ef0d8ea3aea91f07ee1cf06b7b02118f2f"}, - {file = "charset_normalizer-3.3.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0c8c61fb505c7dad1d251c284e712d4e0372cef3b067f7ddf82a7fa82e1e9a93"}, - {file = "charset_normalizer-3.3.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2c092be3885a1b7899cd85ce24acedc1034199d6fca1483fa2c3a35c86e43041"}, - {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2000c54c395d9e5e44c99dc7c20a64dc371f777faf8bae4919ad3e99ce5253e"}, - {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4cb50a0335382aac15c31b61d8531bc9bb657cfd848b1d7158009472189f3d62"}, - {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c30187840d36d0ba2893bc3271a36a517a717f9fd383a98e2697ee890a37c273"}, - {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe81b35c33772e56f4b6cf62cf4aedc1762ef7162a31e6ac7fe5e40d0149eb67"}, - {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d0bf89afcbcf4d1bb2652f6580e5e55a840fdf87384f6063c4a4f0c95e378656"}, - {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:06cf46bdff72f58645434d467bf5228080801298fbba19fe268a01b4534467f5"}, - {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:3c66df3f41abee950d6638adc7eac4730a306b022570f71dd0bd6ba53503ab57"}, - {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:cd805513198304026bd379d1d516afbf6c3c13f4382134a2c526b8b854da1c2e"}, - {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:9505dc359edb6a330efcd2be825fdb73ee3e628d9010597aa1aee5aa63442e97"}, - {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:31445f38053476a0c4e6d12b047b08ced81e2c7c712e5a1ad97bc913256f91b2"}, - {file = "charset_normalizer-3.3.1-cp39-cp39-win32.whl", hash = "sha256:bd28b31730f0e982ace8663d108e01199098432a30a4c410d06fe08fdb9e93f4"}, - {file = "charset_normalizer-3.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:555fe186da0068d3354cdf4bbcbc609b0ecae4d04c921cc13e209eece7720727"}, - {file = "charset_normalizer-3.3.1-py3-none-any.whl", hash = "sha256:800561453acdecedaac137bf09cd719c7a440b6800ec182f077bb8e7025fb708"}, + {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, + {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index c51a57c2..b0057fd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "Boost security in your dev lifecycle via SAST, SCA, Secrets & IaC keywords=["secret-scan", "cycode", "devops", "token", "secret", "security", "cycode", "code"] authors = ["Cycode "] license = "MIT" -repository = "https://github.com/cycodehq-public/cycode-cli" +repository = "https://github.com/cycodehq/cycode-cli" readme = "README.md" classifiers = [ "Development Status :: 5 - Production/Stable", From 774d731b48a7b1ab6ea53ec8ccc8105935c20f70 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Mon, 20 Nov 2023 16:07:34 +0100 Subject: [PATCH 046/257] CM-28796 - Update the CLI documentation regarding committing config file with ignores to repo (#177) --- README.md | 237 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 159 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index e1efe9d9..301e8944 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ This guide will guide you through both installation and usage. 1. [Prerequisites](#prerequisites) 2. [Installation](#installation) 1. [Install Cycode CLI](#install-cycode-cli) - 1. [Use `auth` command](#use-auth-command) - 2. [Use `configure` command](#use-configure-command) + 1. [Using the Auth Command](#using-the-auth-command) + 2. [Using the Configure Command](#using-the-configure-command) 3. [Add to Environment Variables](#add-to-environment-variables) 1. [On Unix/Linux](#on-unixlinux) 2. [On Windows](#on-windows) @@ -44,22 +44,22 @@ This guide will guide you through both installation and usage. 3. [Ignoring a Path](#ignoring-a-path) 4. [Ignoring a Secret, IaC, or SCA Rule](#ignoring-a-secret-iac-sca-or-sast-rule) 5. [Ignoring a Package](#ignoring-a-package) + 6. [Ignoring using config file](#ignoring-using-config-file) 5. [Report command](#report-command) - 1. [Generating Report](#generating-report) - 2. [Report Result](#report-results) + 1. [Generating SBOM Report](#generating-sbom-report) 6. [Syntax Help](#syntax-help) # Prerequisites - The Cycode CLI application requires Python version 3.7 or later. -- Use the [`cycode auth` command](#use-auth-command) to authenticate to Cycode with the CLI +- Use the [`cycode auth` command](#using-the-auth-command) to authenticate to Cycode with the CLI - Alternatively, you can obtain a Cycode Client ID and Client Secret Key by following the steps detailed in the [Service Account Token](https://docs.cycode.com/reference/creating-a-service-account-access-token) and [Personal Access Token](https://docs.cycode.com/reference/creating-a-personal-access-token-1) pages, which contain details on obtaining these values. # Installation The following installation steps are applicable to both Windows and UNIX / Linux operating systems. -> :memo: **Note**
+> [!NOTE] > The following steps assume the use of `python3` and `pip3` for Python-related commands; however, some systems may instead use the `python` and `pip` commands, depending on your Python environment’s configuration. ## Install Cycode CLI @@ -76,13 +76,13 @@ To install the Cycode CLI application on your local machine, perform the followi 4. There are three methods to set the Cycode client ID and client secret: - - [cycode auth](#use-auth-command) (**Recommended**) - - [cycode configure](#use-configure-command) + - [cycode auth](#using-the-auth-command) (**Recommended**) + - [cycode configure](#using-the-configure-command) - Add them to your [environment variables](#add-to-environment-variables) -### Useing the Auth Command +### Using the Auth Command -> :memo: **Note**
+> [!NOTE] > This is the **recommended** method for setting up your local machine to authenticate with Cycode CLI. 1. Type the following command into your terminal/command line window: @@ -91,24 +91,24 @@ To install the Cycode CLI application on your local machine, perform the followi 2. A browser window will appear, asking you to log into Cycode (as seen below): -![Cycode login](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/cycode_login.png) + ![Cycode login](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/cycode_login.png) 3. Enter your login credentials on this page and log in. 4. You will eventually be taken to the page below, where you'll be asked to choose the business group you want to authorize Cycode with (if applicable): -![authorize CLI](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/authorize_cli.png) + ![authorize CLI](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/authorize_cli.png) -> :memo: **Note**
-> This will be the default method for authenticating with the Cycode CLI. + > [!NOTE] + > This will be the default method for authenticating with the Cycode CLI. 5. Click the **Allow** button to authorize the Cycode CLI on the selected business group. -![allow CLI](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/allow_cli.png) + ![allow CLI](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/allow_cli.png) 6. Once completed, you'll see the following screen, if it was selected successfully: -![successfully auth](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/successfully_auth.png) + ![successfully auth](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/successfully_auth.png) 7. In the terminal/command line screen, you will see the following when exiting the browser window: @@ -118,7 +118,7 @@ To install the Cycode CLI application on your local machine, perform the followi ### Using the Configure Command -> :memo: **Note**
+> [!NOTE] > If you already set up your Cycode Client ID and Client Secret through the Linux or Windows environment variables, those credentials will take precedent over this method. 1. Type the following command into your terminal/command line window: @@ -177,19 +177,19 @@ export CYCODE_CLIENT_SECRET={your Cycode Secret Key} 1. From the Control Panel, navigate to the System menu: -![](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/image1.png) + ![](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/image1.png) 2. Next, click Advanced system settings: -![](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/image2.png) + ![](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/image2.png) 3. In the System Properties window that opens, click the Environment Variables button: -![](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/image3.png) + ![](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/image3.png) 4. Create `CYCODE_CLIENT_ID` and `CYCODE_CLIENT_SECRET` variables with values matching your ID and Secret Key, respectively: -![](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/image4.png) + ![](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/image4.png) 5. Insert the cycode.exe into the path to complete the installation. @@ -207,21 +207,21 @@ Perform the following steps to install the pre-commit hook: 3. Create a new YAML file named `.pre-commit-config.yaml` (include the beginning `.`) in the repository’s top directory that contains the following: -```yaml -repos: - - repo: https://github.com/cycodehq/cycode-cli - rev: v1.4.0 - hooks: - - id: cycode - stages: - - commit -``` + ```yaml + repos: + - repo: https://github.com/cycodehq/cycode-cli + rev: v1.4.0 + hooks: + - id: cycode + stages: + - commit + ``` 4. Install Cycode’s hook: `pre-commit install` -> :memo: **Note**
+> [!NOTE] > A successful hook installation will result in the message:
`Pre-commit installed at .git/hooks/pre-commit` @@ -235,14 +235,14 @@ The following are the options and commands available with the Cycode CLI applica | `-v`, `--verbose` | Show detailed logs. | | `--help` | Show options for given command. | -| Command | Description | -|-------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------| -| [auth](#use-auth-command) | Authenticates your machine to associate CLI with your Cycode account. | -| [configure](#use-configure-command) | Initial command to authenticate your CLI client with Cycode using client ID and client secret. | -| [ignore](#ingoring-scan-results) | Ignore a specific value, path or rule ID. | -| [scan](#running-a-scan) | Scan content for secrets/IaC/SCA/SAST violations. You need to specify which scan type: `ci`/`commit_history`/`path`/`repository`/etc. | -| [report](#running-a-report) | Generate report for SCA SBOM. | -| version | Show the version and exit. | +| Command | Description | +|-------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------| +| [auth](#using-the-auth-command) | Authenticates your machine to associate CLI with your Cycode account. | +| [configure](#using-the-configure-command) | Initial command to authenticate your CLI client with Cycode using client ID and client secret. | +| [ignore](#ignoring-scan-results) | Ignore a specific value, path or rule ID. | +| [scan](#running-a-scan) | Scan content for secrets/IaC/SCA/SAST violations. You need to specify which scan type: `ci`/`commit_history`/`path`/`repository`/etc. | +| [report](#report-command) | Generate report for SCA SBOM. | +| version | Show the version and exit. | # Scan Command @@ -285,8 +285,8 @@ For example, consider a scenario in which you want to scan your repository store The following option is available for use with this command: -| Option | Description | -|---------------------|-------------| +| Option | Description | +|---------------------|--------------------------------------------------------| | `-b, --branch TEXT` | Branch to scan, if not set scanning the default branch | #### Branch Option @@ -303,7 +303,7 @@ or: ### Monitor Option -> :memo: **Note**
+> [!NOTE] > This option is only available to SCA scans. To push scan results tied to the [SCA policies](https://docs.cycode.com/docs/sca-policies) found in an SCA type scan to Cycode's knowledge graph, add the argument `--monitor` to the scan command. @@ -318,12 +318,12 @@ or: When using this option, the scan results from this scan will appear in the knowledge graph, which can be found [here](https://app.cycode.com/query-builder). -> :warning: **NOTE**
+> [!WARNING] > You must be an `owner` or an `admin` in Cycode to view the knowledge graph page. ### Report Option -> :memo: **Note**
+> [!NOTE] > This option is only available to SCA scans. To push scan results tied to the [SCA policies](https://docs.cycode.com/docs/sca-policies) found in the Repository scan to Cycode, add the argument `--report` to the scan command. @@ -386,7 +386,7 @@ The report page will look something like below: ### Package Vulnerabilities Option -> :memo: **Note**
+> [!NOTE] > This option is only available to SCA scans. To scan a specific package vulnerability of your local repository, add the argument `--sca-scan package-vulnerabilities` following the `-t sca` or `--scan-type sca` option. @@ -401,7 +401,7 @@ or: #### License Compliance Option -> :memo: **Note**
+> [!NOTE] > This option is only available to SCA scans. To scan a specific branch of your local repository, add the argument `--sca-scan license-compliance` followed by the name of the branch you wish to scan. @@ -416,7 +416,7 @@ or: #### Severity Threshold -> :memo: **Note**
+> [!NOTE] > This option is only available to SCA scans. To limit the results of the `sca` scan to a specific severity threshold, add the argument `--severity-threshold` to the scan command. @@ -448,29 +448,24 @@ Cycode CLI supports Terraform plan scanning (supporting Terraform 0.12 and later Terraform plan file must be in JSON format (having `.json` extension) - _How to generate a Terraform plan from Terraform configuration file?_ 1. Initialize a working directory that contains Terraform configuration file: `terraform init` - 2. Create Terraform execution plan and save the binary output: `terraform plan -out={tfplan_output}` - 3. Convert the binary output file into readable JSON: `terraform show -json {tfplan_output} > {tfplan}.json` - 4. Scan your `{tfplan}.json` with Cycode CLI: `cycode scan -t iac path ~/PATH/TO/YOUR/{tfplan}.json` - ### Commit History Scan A commit history scan is limited to a local repository’s previous commits, focused on finding any secrets within the commit history, instead of examining the repository’s current state. @@ -485,8 +480,8 @@ For example, consider a scenario in which you want to scan the commit history fo The following options are available for use with this command: -| Option | Description | -|---------------------------|-------------| +| Option | Description | +|---------------------------|----------------------------------------------------------------------------------------------------------| | `-r, --commit_range TEXT` | Scan a commit range in this git repository, by default cycode scans all commit history (example: HEAD~1) | #### Commit Range Option @@ -608,15 +603,15 @@ Ignore rules can be added to ignore specific secret values, specific SHA512 valu The following are the options available for the `cycode ignore` command: -| Option | Description | -|---------------------------------|-------------| -| `--by-value TEXT` | Ignore a specific value while scanning for secrets. See [Ignoring a Secret Value](#ignoring-a-secret-value) for more details. | -| `--by-sha TEXT` | Ignore a specific SHA512 representation of a string while scanning for secrets. See [Ignoring a Secret SHA Value](#ignoring-a-secret-sha-value) for more details. | -| `--by-path TEXT` | Avoid scanning a specific path. Need to specify scan type. See [Ignoring a Path](#ignoring-a-path) for more details. | -| `--by-rule TEXT` | Ignore scanning a specific secret rule ID/IaC rule ID/SCA rule ID. See [Ignoring a Secret or Iac Rule](#ignoring-a-secret-or-iac-rule) for more details. | -| `--by-package TEXT` | Ignore scanning a specific package version while running an SCA scan. Expected pattern - `name@version`. See [Ignoring a Package](#ignoring-a-package) for more details. | -| `-t, --scan-type [secret\|iac\|sca\|sast]` | Specify the scan you wish to execute (`secret`/`iac`/`sca`/`sast`), The default value is `secret` | -| `-g, --global` | Add an ignore rule and update it in the global `.cycode` config file | +| Option | Description | +|--------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `--by-value TEXT` | Ignore a specific value while scanning for secrets. See [Ignoring a Secret Value](#ignoring-a-secret-value) for more details. | +| `--by-sha TEXT` | Ignore a specific SHA512 representation of a string while scanning for secrets. See [Ignoring a Secret SHA Value](#ignoring-a-secret-sha-value) for more details. | +| `--by-path TEXT` | Avoid scanning a specific path. Need to specify scan type. See [Ignoring a Path](#ignoring-a-path) for more details. | +| `--by-rule TEXT` | Ignore scanning a specific secret rule ID/IaC rule ID/SCA rule ID. See [Ignoring a Secret or Iac Rule](#ignoring-a-secret-iac-sca-or-sast-rule) for more details. | +| `--by-package TEXT` | Ignore scanning a specific package version while running an SCA scan. Expected pattern - `name@version`. See [Ignoring a Package](#ignoring-a-package) for more details. | +| `-t, --scan-type [secret\|iac\|sca\|sast]` | Specify the scan you wish to execute (`secret`/`iac`/`sca`/`sast`), The default value is `secret` | +| `-g, --global` | Add an ignore rule and update it in the global `.cycode` config file | In the following example, a pre-commit scan runs and finds the following: @@ -628,7 +623,7 @@ Secret SHA: a44081db3296c84b82d12a35c446a3cba19411dddfa0380134c75f7b3973bff0 2 | \ No newline at end of file ``` -If this is a value that is not a valid secret, then use the the `cycode ignore` command to ignore the secret by its value, SHA value, specific path, or rule ID. If this is an IaC scan, then you can ignore that result by its path or rule ID. +If this is a value that is not a valid secret, then use the `cycode ignore` command to ignore the secret by its value, SHA value, specific path, or rule ID. If this is an IaC scan, then you can ignore that result by its path or rule ID. ### Ignoring a Secret Value @@ -712,7 +707,7 @@ In the example above, replace the `dc21bc6b-9f4f-46fb-9f92-e4327ea03f6b` value w ### Ignoring a Package -> :memo: **Note**
+> [!NOTE] > This option is only available to the SCA scans. To ignore a specific package in the SCA scans, you will need to use the `--by-package` flag in conjunction with the `-t, --scan-type` flag (you must specify the `sca` scan type). This will ignore the given package, using the `{{package_name}}@{{package_version}}` formatting, from all future scans. Use the following command to add a package and version to be ignored: @@ -729,6 +724,90 @@ In the example below, the command to ignore a specific SCA package is as follows In the example above, replace `pyyaml` with package name and `5.3.1` with the package version you want to ignore. +### Ignoring using config file + +The applied ignoring rules are stored in the configuration file called `config.yaml`. +This file could be easily shared between developers or even committed to remote Git. +These files are always located in the `.cycode` folder. +The folder starts with a dot (.), and you should enable the displaying of hidden files to see it. + +#### Path of the config files + +By default, all `cycode ignore` commands save the ignoring rule to the current directory from which CLI has been run. + +Example: running ignoring CLI command from `/Users/name/projects/backend` will create `config.yaml` in `/Users/name/projects/backend/.cycode` + +```shell +➜ backend pwd +/Users/name/projects/backend +➜ backend cycode ignore --by-value test-value +➜ backend tree -a +. +└── .cycode + └── config.yaml + +2 directories, 1 file +``` + +The second option is to save ignoring rules to the global configuration files. +The path of the global config is `~/.cycode/config.yaml`, +where `~` means user\`s home directory, for example, `/Users/name` on macOS. + +Saving to the global space could be performed with the `-g` flag of the `cycode ignore` command. +For example: `cycode ignore -g --by-value test-value`. + +#### Proper working directory + +This is incredibly important to place the `.cycode` folder and run CLI from the same place. +You should double-check it when working with different environments like CI/CD (GitHub Actions, Jenkins, etc.). + +You could commit the `.cycode` folder to the root of your repository. +In this scenario, you must run CLI scans from the repository root. +If it doesn't fit your requirements, you could temporarily copy the `.cycode` folder +wherever you want and perform a CLI scan from this folder. + +#### Structure ignoring rules in the config + +It's important to understand how CLI stores ignore rules to be able to read these configuration files or even modify them without CLI. + +The abstract YAML structure: +```yaml +exclusions: + *scanTypeName*: + *ignoringType: + - *ignoringValue1* + - *ignoringValue2* +``` + +Possible values of `scanTypeName`: `iac`, `sca`, `sast`, `secret`. +Possible values of `ignoringType`: `paths`, `values`, `rules`, `packages`, `shas`. + +> [!WARNING] +> Values for "ignore by value" are not stored as plain text! +> CLI stores sha256 hashes of the values instead. +> You should put hashes of the string when modifying the configuration file by hand. + +Example of real `config.yaml`: +```yaml +exclusions: + iac: + rules: + - bdaa88e2-5e7c-46ff-ac2a-29721418c59c + sca: + packages: + - pyyaml@5.3.1 + secret: + paths: + - /Users/name/projects/build + rules: + - ce3a4de0-9dfc-448b-a004-c538cf8b4710 + shas: + - a44081db3296c84b82d12a35c446a3cba19411dddfa0380134c75f7b3973bff0 + values: + - a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3 + - 60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752 +``` + # Report Command ## Generating SBOM Report @@ -737,19 +816,21 @@ A software bill of materials (SBOM) is an inventory of all constituent component Using this command you can create an SBOM report for your local project or for your repository URI. The following options are available for use with this command: -| Option | Description | Required | Default | -|---------------------|-------------|----------|---------| -| `-f, --format [spdx-2.2\|spdx-2.3\|cyclonedx-1.4]` | SBOM format | Yes | | -| `-o, --output-format [JSON]` | Specify the output file format | No | json | -| `--output-file PATH` | Output file | No | autogenerated filename saved to the current directory | -| `--include-vulnerabilities` | Include vulnerabilities | No | False | -| `--include-dev-dependencies` | Include dev dependencies | No | False | - -The following commands are available for use with this command: -| Command | Description | -|---------------------|-------------| -| `path` | Generate SBOM report for provided path in the command | -| `repository_url` | Generate SBOM report for provided repository URI in the command | + +| Option | Description | Required | Default | +|----------------------------------------------------|--------------------------------|----------|-------------------------------------------------------| +| `-f, --format [spdx-2.2\|spdx-2.3\|cyclonedx-1.4]` | SBOM format | Yes | | +| `-o, --output-format [JSON]` | Specify the output file format | No | json | +| `--output-file PATH` | Output file | No | autogenerated filename saved to the current directory | +| `--include-vulnerabilities` | Include vulnerabilities | No | False | +| `--include-dev-dependencies` | Include dev dependencies | No | False | + +The following commands are available for use with this command: + +| Command | Description | +|------------------|-----------------------------------------------------------------| +| `path` | Generate SBOM report for provided path in the command | +| `repository_url` | Generate SBOM report for provided repository URI in the command | ### Repository From eb488c4efb3bb2402884c975524d7a722a950eb4 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 29 Nov 2023 17:39:37 +0100 Subject: [PATCH 047/257] CM-26396 - Add Elixir support for SCA (#179) --- cycode/cli/consts.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 9479765e..bbae235b 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -79,6 +79,8 @@ 'pipfile.lock', 'requirements.txt', 'setup.py', + 'mix.exs', + 'mix.lock', ) SCA_EXCLUDED_PATHS = ('node_modules',) @@ -97,6 +99,7 @@ 'pypi_pipenv': ['Pipfile', 'Pipfile.lock'], 'pypi_requirements': ['requirements.txt'], 'pypi_setup': ['setup.py'], + 'hex': ['mix.exs', 'mix.lock'], } COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES = [SECRET_SCAN_TYPE, SCA_SCAN_TYPE] From b4f510e8f70a798473deedd58cc0a785e1be1f53 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Thu, 30 Nov 2023 10:30:15 +0100 Subject: [PATCH 048/257] CM-27046 - Improve UX of SCA table with results by grouping and sorting data (#180) --- .../cli/printers/tables/sca_table_printer.py | 74 +++++++++++++++---- 1 file changed, 60 insertions(+), 14 deletions(-) diff --git a/cycode/cli/printers/tables/sca_table_printer.py b/cycode/cli/printers/tables/sca_table_printer.py index 92247d41..e9c0e937 100644 --- a/cycode/cli/printers/tables/sca_table_printer.py +++ b/cycode/cli/printers/tables/sca_table_printer.py @@ -4,7 +4,7 @@ import click from cycode.cli.consts import LICENSE_COMPLIANCE_POLICY_ID, PACKAGE_VULNERABILITY_POLICY_ID -from cycode.cli.models import Detection +from cycode.cli.models import Detection, Severity from cycode.cli.printers.tables.table import Table from cycode.cli.printers.tables.table_models import ColumnInfoBuilder, ColumnWidths from cycode.cli.printers.tables.table_printer_base import TablePrinterBase @@ -19,21 +19,21 @@ # Building must have strict order. Represents the order of the columns in the table (from left to right) SEVERITY_COLUMN = column_builder.build(name='Severity') REPOSITORY_COLUMN = column_builder.build(name='Repository') - -FILE_PATH_COLUMN = column_builder.build(name='File Path') +CODE_PROJECT_COLUMN = column_builder.build(name='Code Project') # File path to manifest file ECOSYSTEM_COLUMN = column_builder.build(name='Ecosystem') -DEPENDENCY_NAME_COLUMN = column_builder.build(name='Dependency Name') -DIRECT_DEPENDENCY_COLUMN = column_builder.build(name='Direct Dependency') -DEVELOPMENT_DEPENDENCY_COLUMN = column_builder.build(name='Development Dependency') -DEPENDENCY_PATHS_COLUMN = column_builder.build(name='Dependency Paths') - +PACKAGE_COLUMN = column_builder.build(name='Package') CVE_COLUMNS = column_builder.build(name='CVE') +DEPENDENCY_PATHS_COLUMN = column_builder.build(name='Dependency Paths') UPGRADE_COLUMN = column_builder.build(name='Upgrade') LICENSE_COLUMN = column_builder.build(name='License') +DIRECT_DEPENDENCY_COLUMN = column_builder.build(name='Direct Dependency') +DEVELOPMENT_DEPENDENCY_COLUMN = column_builder.build(name='Development Dependency') + COLUMN_WIDTHS_CONFIG: ColumnWidths = { REPOSITORY_COLUMN: 2, - FILE_PATH_COLUMN: 3, + CODE_PROJECT_COLUMN: 2, + PACKAGE_COLUMN: 3, CVE_COLUMNS: 5, UPGRADE_COLUMN: 3, LICENSE_COLUMN: 2, @@ -47,7 +47,7 @@ def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: table = self._get_table(policy_id) table.set_cols_width(COLUMN_WIDTHS_CONFIG) - for detection in detections: + for detection in self._sort_and_group_detections(detections): self._enrich_table_with_values(table, detection) self._print_summary_issues(len(detections), self._get_title(policy_id)) @@ -64,6 +64,52 @@ def _get_title(policy_id: str) -> str: return 'Unknown' + @staticmethod + def __group_by(detections: List[Detection], details_field_name: str) -> Dict[str, List[Detection]]: + grouped = defaultdict(list) + for detection in detections: + grouped[detection.detection_details.get(details_field_name)].append(detection) + return grouped + + @staticmethod + def __severity_sort_key(detection: Detection) -> int: + severity = detection.detection_details.get('advisory_severity') + return Severity.try_get_value(severity) + + def _sort_detections_by_severity(self, detections: List[Detection]) -> List[Detection]: + return sorted(detections, key=self.__severity_sort_key, reverse=True) + + @staticmethod + def __package_sort_key(detection: Detection) -> int: + return detection.detection_details.get('package_name') + + def _sort_detections_by_package(self, detections: List[Detection]) -> List[Detection]: + return sorted(detections, key=self.__package_sort_key) + + def _sort_and_group_detections(self, detections: List[Detection]) -> List[Detection]: + """Sort detections by severity and group by repository, code project and package name. + + Note: + Code Project is path to manifest file. + + Grouping by code projects also groups by ecosystem. + Because manifest files are unique per ecosystem. + """ + result = [] + + # we sort detections by package name to make persist output order + sorted_detections = self._sort_detections_by_package(detections) + + grouped_by_repository = self.__group_by(sorted_detections, 'repository_name') + for repository_group in grouped_by_repository.values(): + grouped_by_code_project = self.__group_by(repository_group, 'file_name') + for code_project_group in grouped_by_code_project.values(): + grouped_by_package = self.__group_by(code_project_group, 'package_name') + for package_group in grouped_by_package.values(): + result.extend(self._sort_detections_by_severity(package_group)) + + return result + def _get_table(self, policy_id: str) -> Table: table = Table() @@ -77,9 +123,9 @@ def _get_table(self, policy_id: str) -> Table: if self._is_git_repository(): table.add(REPOSITORY_COLUMN) - table.add(FILE_PATH_COLUMN) + table.add(CODE_PROJECT_COLUMN) table.add(ECOSYSTEM_COLUMN) - table.add(DEPENDENCY_NAME_COLUMN) + table.add(PACKAGE_COLUMN) table.add(DIRECT_DEPENDENCY_COLUMN) table.add(DEVELOPMENT_DEPENDENCY_COLUMN) table.add(DEPENDENCY_PATHS_COLUMN) @@ -93,9 +139,9 @@ def _enrich_table_with_values(table: Table, detection: Detection) -> None: table.set(SEVERITY_COLUMN, detection_details.get('advisory_severity')) table.set(REPOSITORY_COLUMN, detection_details.get('repository_name')) - table.set(FILE_PATH_COLUMN, detection_details.get('file_name')) + table.set(CODE_PROJECT_COLUMN, detection_details.get('file_name')) table.set(ECOSYSTEM_COLUMN, detection_details.get('ecosystem')) - table.set(DEPENDENCY_NAME_COLUMN, detection_details.get('package_name')) + table.set(PACKAGE_COLUMN, detection_details.get('package_name')) table.set(DIRECT_DEPENDENCY_COLUMN, detection_details.get('is_direct_dependency_str')) table.set(DEVELOPMENT_DEPENDENCY_COLUMN, detection_details.get('is_dev_dependency_str')) From 91d3ea57edcb48a2f09e8ce072e1870f2e781aa8 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Mon, 4 Dec 2023 14:39:44 +0100 Subject: [PATCH 049/257] CM-29799 - Revert async flow for secrets (#181) --- cycode/cli/code_scanner.py | 12 ++++-------- cycode/cyclient/scan_config_base.py | 6 ++---- tests/cli/test_main.py | 10 ++++++---- tests/cyclient/mocked_responses/scan_client.py | 9 +++++++++ tests/cyclient/test_scan_client.py | 8 ++++++++ 5 files changed, 29 insertions(+), 16 deletions(-) diff --git a/cycode/cli/code_scanner.py b/cycode/cli/code_scanner.py index 5920fc80..1801d63c 100644 --- a/cycode/cli/code_scanner.py +++ b/cycode/cli/code_scanner.py @@ -546,13 +546,13 @@ def perform_scan( is_commit_range: bool, scan_parameters: dict, ) -> ZippedFileScanResult: + if scan_type in (consts.SCA_SCAN_TYPE, consts.SAST_SCAN_TYPE): + return perform_scan_async(cycode_client, zipped_documents, scan_type, scan_parameters) + if is_commit_range: return cycode_client.commit_range_zipped_file_scan(scan_type, zipped_documents, scan_id) - if scan_type == consts.INFRA_CONFIGURATION_SCAN_TYPE: - return cycode_client.zipped_file_scan(scan_type, zipped_documents, scan_id, scan_parameters, is_git_diff) - - return perform_scan_async(cycode_client, zipped_documents, scan_type, scan_parameters) + return cycode_client.zipped_file_scan(scan_type, zipped_documents, scan_id, scan_parameters, is_git_diff) def perform_scan_async( @@ -1025,10 +1025,6 @@ def _map_detections_per_file(detections: List[dict]) -> List[DetectionsPerFile]: def _get_file_name_from_detection(detection: dict) -> str: if detection['category'] == 'SAST': return detection['detection_details']['file_path'] - if detection['category'] == 'SecretDetection': - file_path = detection['detection_details']['file_path'] - file_name = detection['detection_details']['file_name'] - return os.path.join(file_path, file_name) return detection['detection_details']['file_name'] diff --git a/cycode/cyclient/scan_config_base.py b/cycode/cyclient/scan_config_base.py index 0fffa1a2..0b791348 100644 --- a/cycode/cyclient/scan_config_base.py +++ b/cycode/cyclient/scan_config_base.py @@ -16,10 +16,8 @@ def get_async_scan_type(scan_type: str) -> str: return scan_type.upper() @staticmethod - def get_async_entity_type(scan_type: str) -> str: - if scan_type == 'secret': - return 'zippedfile' - + def get_async_entity_type(_: str) -> str: + # we are migrating to "zippedfile" entity type. will be used later return 'repository' @abstractmethod diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py index aa196c0e..3f41ed6a 100644 --- a/tests/cli/test_main.py +++ b/tests/cli/test_main.py @@ -1,6 +1,6 @@ import json from typing import TYPE_CHECKING -from uuid import UUID +from uuid import uuid4 import pytest import responses @@ -8,7 +8,8 @@ from cycode.cli.main import main_cli from tests.conftest import CLI_ENV_VARS, TEST_FILES_PATH, ZIP_CONTENT_PATH -from tests.cyclient.mocked_responses.scan_client import mock_scan_async_responses +from tests.cyclient.mocked_responses.scan_client import mock_scan_responses +from tests.cyclient.test_scan_client import get_zipped_file_scan_response, get_zipped_file_scan_url _PATH_TO_SCAN = TEST_FILES_PATH.joinpath('zip_content').absolute() @@ -28,10 +29,11 @@ def _is_json(plain: str) -> bool: @pytest.mark.parametrize('output', ['text', 'json']) def test_passing_output_option(output: str, scan_client: 'ScanClient', api_token_response: responses.Response) -> None: scan_type = 'secret' - scan_id = UUID('12345678-418f-47ee-abb0-012345678901') + scan_id = uuid4() + mock_scan_responses(responses, scan_type, scan_client, scan_id, ZIP_CONTENT_PATH) + responses.add(get_zipped_file_scan_response(get_zipped_file_scan_url(scan_type, scan_client), ZIP_CONTENT_PATH)) responses.add(api_token_response) - mock_scan_async_responses(responses, scan_type, scan_client, scan_id, ZIP_CONTENT_PATH) args = ['--output', output, 'scan', '--soft-fail', 'path', str(_PATH_TO_SCAN)] result = CliRunner().invoke(main_cli, args, env=CLI_ENV_VARS) diff --git a/tests/cyclient/mocked_responses/scan_client.py b/tests/cyclient/mocked_responses/scan_client.py index 8518e2b9..dff10003 100644 --- a/tests/cyclient/mocked_responses/scan_client.py +++ b/tests/cyclient/mocked_responses/scan_client.py @@ -156,3 +156,12 @@ def mock_scan_async_responses( responses_module.add(get_scan_detections_count_response(get_scan_detections_count_url(scan_client))) responses_module.add(get_scan_detections_response(get_scan_detections_url(scan_client), scan_id, zip_content_path)) responses_module.add(get_report_scan_status_response(get_report_scan_status_url(scan_type, scan_id, scan_client))) + + +def mock_scan_responses( + responses_module: responses, scan_type: str, scan_client: ScanClient, scan_id: UUID, zip_content_path: Path +) -> None: + responses_module.add( + get_zipped_file_scan_response(get_zipped_file_scan_url(scan_type, scan_client), zip_content_path) + ) + responses_module.add(get_report_scan_status_response(get_report_scan_status_url(scan_type, scan_id, scan_client))) diff --git a/tests/cyclient/test_scan_client.py b/tests/cyclient/test_scan_client.py index 1f8ed462..6df7b544 100644 --- a/tests/cyclient/test_scan_client.py +++ b/tests/cyclient/test_scan_client.py @@ -37,6 +37,14 @@ def get_test_zip_file(scan_type: str) -> InMemoryZip: return zip_documents(scan_type, test_documents) +def test_get_service_name(scan_client: ScanClient) -> None: + # TODO(MarshalX): get_service_name should be removed from ScanClient? Because it exists in ScanConfig + assert scan_client.get_service_name('secret') == 'secret' + assert scan_client.get_service_name('iac') == 'iac' + assert scan_client.get_service_name('sca') == 'scans' + assert scan_client.get_service_name('sast') == 'scans' + + @pytest.mark.parametrize('scan_type', config['scans']['supported_scans']) @responses.activate def test_zipped_file_scan(scan_type: str, scan_client: ScanClient, api_token_response: responses.Response) -> None: From 16bc696f8635f0a5650d4118a11e39bb87458822 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 6 Dec 2023 13:22:08 +0100 Subject: [PATCH 050/257] CM-29960 - Migrate all CLI commands to new project structure (#182) --- cycode/cli/{auth => commands}/__init__.py | 0 .../cli/commands/auth}/__init__.py | 0 .../cli/{ => commands}/auth/auth_command.py | 6 +- .../cli/{ => commands}/auth/auth_manager.py | 0 cycode/cli/commands/main_cli.py | 79 ++++ .../cli/commands/report/sbom/path/__init__.py | 0 .../path_command.py} | 4 +- .../report/sbom/repository_url/__init__.py | 0 .../repository_url_command.py} | 4 +- .../cli/commands/report/sbom/sbom_command.py | 8 +- cycode/cli/commands/scan/__init__.py | 0 .../cli/{ => commands/scan}/code_scanner.py | 364 ++++-------------- .../commands/scan/commit_history/__init__.py | 0 .../commit_history/commit_history_command.py | 24 ++ cycode/cli/commands/scan/path/__init__.py | 0 cycode/cli/commands/scan/path/path_command.py | 15 + .../cli/commands/scan/pre_commit/__init__.py | 0 .../scan/pre_commit/pre_commit_command.py | 44 +++ .../cli/commands/scan/pre_receive/__init__.py | 0 .../scan/pre_receive/pre_receive_command.py | 62 +++ .../cli/commands/scan/repository/__init__.py | 0 .../scan/repository/repisotiry_command.py | 60 +++ cycode/cli/commands/scan/scan_ci/__init__.py | 0 .../scan/scan_ci}/ci_integrations.py | 0 .../commands/scan/scan_ci/scan_ci_command.py | 15 + cycode/cli/commands/scan/scan_command.py | 159 ++++++++ cycode/cli/commands/version/__init__.py | 0 .../cli/commands/version/version_command.py | 22 ++ .../handle_report_sbom_errors.py} | 0 cycode/cli/exceptions/handle_scan_errors.py | 80 ++++ cycode/cli/main.py | 251 +----------- tests/cli/commands/__init__.py | 0 tests/cli/commands/configure/__init__.py | 0 .../configure}/test_configure_command.py | 0 tests/cli/commands/scan/__init__.py | 0 .../{ => commands/scan}/test_code_scanner.py | 66 ---- .../test_main_command.py} | 2 +- tests/cli/exceptions/__init__.py | 0 .../cli/exceptions/test_handle_scan_errors.py | 67 ++++ tests/cli/files_collector/iac/__init__.py | 0 .../iac}/test_tf_content_generator.py | 0 tests/cyclient/test_auth_client.py | 5 +- tests/cyclient/test_scan_client.py | 2 +- 43 files changed, 711 insertions(+), 628 deletions(-) rename cycode/cli/{auth => commands}/__init__.py (100%) rename {tests/cli/helpers => cycode/cli/commands/auth}/__init__.py (100%) rename cycode/cli/{ => commands}/auth/auth_command.py (95%) rename cycode/cli/{ => commands}/auth/auth_manager.py (100%) create mode 100644 cycode/cli/commands/main_cli.py create mode 100644 cycode/cli/commands/report/sbom/path/__init__.py rename cycode/cli/commands/report/sbom/{sbom_path_command.py => path/path_command.py} (94%) create mode 100644 cycode/cli/commands/report/sbom/repository_url/__init__.py rename cycode/cli/commands/report/sbom/{sbom_repository_url_command.py => repository_url/repository_url_command.py} (92%) create mode 100644 cycode/cli/commands/scan/__init__.py rename cycode/cli/{ => commands/scan}/code_scanner.py (77%) create mode 100644 cycode/cli/commands/scan/commit_history/__init__.py create mode 100644 cycode/cli/commands/scan/commit_history/commit_history_command.py create mode 100644 cycode/cli/commands/scan/path/__init__.py create mode 100644 cycode/cli/commands/scan/path/path_command.py create mode 100644 cycode/cli/commands/scan/pre_commit/__init__.py create mode 100644 cycode/cli/commands/scan/pre_commit/pre_commit_command.py create mode 100644 cycode/cli/commands/scan/pre_receive/__init__.py create mode 100644 cycode/cli/commands/scan/pre_receive/pre_receive_command.py create mode 100644 cycode/cli/commands/scan/repository/__init__.py create mode 100644 cycode/cli/commands/scan/repository/repisotiry_command.py create mode 100644 cycode/cli/commands/scan/scan_ci/__init__.py rename cycode/cli/{ => commands/scan/scan_ci}/ci_integrations.py (100%) create mode 100644 cycode/cli/commands/scan/scan_ci/scan_ci_command.py create mode 100644 cycode/cli/commands/scan/scan_command.py create mode 100644 cycode/cli/commands/version/__init__.py create mode 100644 cycode/cli/commands/version/version_command.py rename cycode/cli/{commands/report/sbom/handle_errors.py => exceptions/handle_report_sbom_errors.py} (100%) create mode 100644 cycode/cli/exceptions/handle_scan_errors.py create mode 100644 tests/cli/commands/__init__.py create mode 100644 tests/cli/commands/configure/__init__.py rename tests/cli/{ => commands/configure}/test_configure_command.py (100%) create mode 100644 tests/cli/commands/scan/__init__.py rename tests/cli/{ => commands/scan}/test_code_scanner.py (55%) rename tests/cli/{test_main.py => commands/test_main_command.py} (96%) create mode 100644 tests/cli/exceptions/__init__.py create mode 100644 tests/cli/exceptions/test_handle_scan_errors.py create mode 100644 tests/cli/files_collector/iac/__init__.py rename tests/cli/{helpers => files_collector/iac}/test_tf_content_generator.py (100%) diff --git a/cycode/cli/auth/__init__.py b/cycode/cli/commands/__init__.py similarity index 100% rename from cycode/cli/auth/__init__.py rename to cycode/cli/commands/__init__.py diff --git a/tests/cli/helpers/__init__.py b/cycode/cli/commands/auth/__init__.py similarity index 100% rename from tests/cli/helpers/__init__.py rename to cycode/cli/commands/auth/__init__.py diff --git a/cycode/cli/auth/auth_command.py b/cycode/cli/commands/auth/auth_command.py similarity index 95% rename from cycode/cli/auth/auth_command.py rename to cycode/cli/commands/auth/auth_command.py index c7853068..51eb212b 100644 --- a/cycode/cli/auth/auth_command.py +++ b/cycode/cli/commands/auth/auth_command.py @@ -2,7 +2,7 @@ import click -from cycode.cli.auth.auth_manager import AuthManager +from cycode.cli.commands.auth.auth_manager import AuthManager from cycode.cli.exceptions.custom_exceptions import AuthProcessError, HttpUnauthorizedError, NetworkError from cycode.cli.models import CliError, CliErrors, CliResult from cycode.cli.printers import ConsolePrinter @@ -15,7 +15,7 @@ invoke_without_command=True, short_help='Authenticate your machine to associate the CLI with your Cycode account.' ) @click.pass_context -def authenticate(context: click.Context) -> None: +def auth_command(context: click.Context) -> None: """Authenticates your machine.""" if context.invoked_subcommand is not None: # if it is a subcommand, do nothing @@ -33,7 +33,7 @@ def authenticate(context: click.Context) -> None: _handle_exception(context, e) -@authenticate.command( +@auth_command.command( name='check', short_help='Checks that your machine is associating the CLI with your Cycode account.' ) @click.pass_context diff --git a/cycode/cli/auth/auth_manager.py b/cycode/cli/commands/auth/auth_manager.py similarity index 100% rename from cycode/cli/auth/auth_manager.py rename to cycode/cli/commands/auth/auth_manager.py diff --git a/cycode/cli/commands/main_cli.py b/cycode/cli/commands/main_cli.py new file mode 100644 index 00000000..e418cf7f --- /dev/null +++ b/cycode/cli/commands/main_cli.py @@ -0,0 +1,79 @@ +import logging +from typing import Optional + +import click + +from cycode.cli.commands.auth.auth_command import auth_command +from cycode.cli.commands.configure.configure_command import configure_command +from cycode.cli.commands.ignore.ignore_command import ignore_command +from cycode.cli.commands.report.report_command import report_command +from cycode.cli.commands.scan.scan_command import scan_command +from cycode.cli.commands.version.version_command import version_command +from cycode.cli.consts import ( + CLI_CONTEXT_SETTINGS, +) +from cycode.cli.user_settings.configuration_manager import ConfigurationManager +from cycode.cli.utils.progress_bar import SCAN_PROGRESS_BAR_SECTIONS, get_progress_bar +from cycode.cyclient.config import set_logging_level +from cycode.cyclient.cycode_client_base import CycodeClientBase +from cycode.cyclient.models import UserAgentOptionScheme + + +@click.group( + commands={ + 'scan': scan_command, + 'report': report_command, + 'configure': configure_command, + 'ignore': ignore_command, + 'auth': auth_command, + 'version': version_command, + }, + context_settings=CLI_CONTEXT_SETTINGS, +) +@click.option( + '--verbose', + '-v', + is_flag=True, + default=False, + help='Show detailed logs.', +) +@click.option( + '--no-progress-meter', + is_flag=True, + default=False, + help='Do not show the progress meter.', +) +@click.option( + '--output', + '-o', + default='text', + help='Specify the output type (the default is text).', + type=click.Choice(['text', 'json', 'table']), +) +@click.option( + '--user-agent', + default=None, + help='Characteristic JSON object that lets servers identify the application.', + type=str, +) +@click.pass_context +def main_cli( + context: click.Context, verbose: bool, no_progress_meter: bool, output: str, user_agent: Optional[str] +) -> None: + context.ensure_object(dict) + configuration_manager = ConfigurationManager() + + verbose = verbose or configuration_manager.get_verbose_flag() + context.obj['verbose'] = verbose + if verbose: + set_logging_level(logging.DEBUG) + + context.obj['output'] = output + if output == 'json': + no_progress_meter = True + + context.obj['progress_bar'] = get_progress_bar(hidden=no_progress_meter, sections=SCAN_PROGRESS_BAR_SECTIONS) + + if user_agent: + user_agent_option = UserAgentOptionScheme().loads(user_agent) + CycodeClientBase.enrich_user_agent(user_agent_option.user_agent_suffix) diff --git a/cycode/cli/commands/report/sbom/path/__init__.py b/cycode/cli/commands/report/sbom/path/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/commands/report/sbom/sbom_path_command.py b/cycode/cli/commands/report/sbom/path/path_command.py similarity index 94% rename from cycode/cli/commands/report/sbom/sbom_path_command.py rename to cycode/cli/commands/report/sbom/path/path_command.py index 23062cab..323eb62b 100644 --- a/cycode/cli/commands/report/sbom/sbom_path_command.py +++ b/cycode/cli/commands/report/sbom/path/path_command.py @@ -4,7 +4,7 @@ from cycode.cli import consts from cycode.cli.commands.report.sbom.common import create_sbom_report, send_report_feedback -from cycode.cli.commands.report.sbom.handle_errors import handle_report_exception +from cycode.cli.exceptions.handle_report_sbom_errors import handle_report_exception from cycode.cli.files_collector.path_documents import get_relevant_document 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 @@ -15,7 +15,7 @@ @click.command(short_help='Generate SBOM report for provided path in the command.') @click.argument('path', nargs=1, type=click.Path(exists=True, resolve_path=True), required=True) @click.pass_context -def sbom_path_command(context: click.Context, path: str) -> None: +def path_command(context: click.Context, path: str) -> None: 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/__init__.py b/cycode/cli/commands/report/sbom/repository_url/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/commands/report/sbom/sbom_repository_url_command.py b/cycode/cli/commands/report/sbom/repository_url/repository_url_command.py similarity index 92% rename from cycode/cli/commands/report/sbom/sbom_repository_url_command.py rename to cycode/cli/commands/report/sbom/repository_url/repository_url_command.py index 4d5ee4a3..4f54cac1 100644 --- a/cycode/cli/commands/report/sbom/sbom_repository_url_command.py +++ b/cycode/cli/commands/report/sbom/repository_url/repository_url_command.py @@ -3,7 +3,7 @@ import click from cycode.cli.commands.report.sbom.common import create_sbom_report, send_report_feedback -from cycode.cli.commands.report.sbom.handle_errors import handle_report_exception +from cycode.cli.exceptions.handle_report_sbom_errors import handle_report_exception from cycode.cli.utils.get_api_client import get_report_cycode_client from cycode.cli.utils.progress_bar import SbomReportProgressBarSection @@ -11,7 +11,7 @@ @click.command(short_help='Generate SBOM report for provided repository URI in the command.') @click.argument('uri', nargs=1, type=str, required=True) @click.pass_context -def sbom_repository_url_command(context: click.Context, uri: str) -> None: +def repository_url_command(context: click.Context, uri: str) -> None: 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 ecfd2782..870f4e0c 100644 --- a/cycode/cli/commands/report/sbom/sbom_command.py +++ b/cycode/cli/commands/report/sbom/sbom_command.py @@ -3,16 +3,16 @@ import click -from cycode.cli.commands.report.sbom.sbom_path_command import sbom_path_command -from cycode.cli.commands.report.sbom.sbom_repository_url_command import sbom_repository_url_command +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.cyclient.report_client import ReportParameters @click.group( commands={ - 'path': sbom_path_command, - 'repository_url': sbom_repository_url_command, + 'path': path_command, + 'repository_url': repository_url_command, }, short_help='Generate SBOM report for remote repository by url or local directory by path.', ) diff --git a/cycode/cli/commands/scan/__init__.py b/cycode/cli/commands/scan/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/code_scanner.py b/cycode/cli/commands/scan/code_scanner.py similarity index 77% rename from cycode/cli/code_scanner.py rename to cycode/cli/commands/scan/code_scanner.py index 1801d63c..be1014de 100644 --- a/cycode/cli/code_scanner.py +++ b/cycode/cli/commands/scan/code_scanner.py @@ -3,34 +3,30 @@ import os import sys import time -import traceback from platform import platform from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple from uuid import UUID, uuid4 import click -from git import NULL_TREE, InvalidGitRepositoryError, Repo +from git import NULL_TREE, Repo from cycode.cli import consts -from cycode.cli.ci_integrations import get_commit_range from cycode.cli.config import configuration_manager from cycode.cli.exceptions import custom_exceptions +from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception from cycode.cli.files_collector.excluder import exclude_irrelevant_documents_to_scan from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip from cycode.cli.files_collector.path_documents import get_relevant_document from cycode.cli.files_collector.repository_documents import ( - calculate_pre_receive_commit_range, get_commit_range_modified_documents, - get_diff_file_content, get_diff_file_path, - get_git_repository_tree_file_entries, get_pre_commit_modified_documents, parse_commit_range, ) from cycode.cli.files_collector.sca import sca_code_scanner 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.models import CliError, CliErrors, Document, DocumentDetections, LocalScanResult, Severity +from cycode.cli.models import CliError, Document, DocumentDetections, LocalScanResult, Severity from cycode.cli.printers import ConsolePrinter from cycode.cli.utils import scan_utils from cycode.cli.utils.path_utils import ( @@ -39,7 +35,6 @@ from cycode.cli.utils.progress_bar import ScanProgressBarSection from cycode.cli.utils.scan_batch import run_parallel_batched_scan from cycode.cli.utils.scan_utils import set_issue_detected -from cycode.cli.utils.task_timer import TimeoutAfter from cycode.cyclient import logger from cycode.cyclient.config import set_logging_level from cycode.cyclient.models import Detection, DetectionSchema, DetectionsPerFile, ZippedFileScanResult @@ -51,221 +46,6 @@ start_scan_time = time.time() -@click.command(short_help='Scan the git repository including its history.') -@click.argument('path', nargs=1, type=click.Path(exists=True, resolve_path=True), required=True) -@click.option( - '--branch', - '-b', - default=None, - help='Branch to scan, if not set scanning the default branch', - type=str, - required=False, -) -@click.pass_context -def scan_repository(context: click.Context, path: str, branch: str) -> None: - try: - logger.debug('Starting repository scan process, %s', {'path': path, 'branch': branch}) - - scan_type = context.obj['scan_type'] - monitor = context.obj.get('monitor') - if monitor and scan_type != consts.SCA_SCAN_TYPE: - raise click.ClickException('Monitor flag is currently supported for SCA scan type only') - - progress_bar = context.obj['progress_bar'] - progress_bar.start() - - file_entries = list(get_git_repository_tree_file_entries(path, branch)) - progress_bar.set_section_length(ScanProgressBarSection.PREPARE_LOCAL_FILES, len(file_entries)) - - documents_to_scan = [] - for file in file_entries: - # FIXME(MarshalX): probably file could be tree or submodule too. we expect blob only - progress_bar.update(ScanProgressBarSection.PREPARE_LOCAL_FILES) - - file_path = file.path if monitor else get_path_by_os(os.path.join(path, file.path)) - documents_to_scan.append(Document(file_path, file.data_stream.read().decode('UTF-8', errors='replace'))) - - documents_to_scan = exclude_irrelevant_documents_to_scan(scan_type, documents_to_scan) - - perform_pre_scan_documents_actions(context, scan_type, documents_to_scan, is_git_diff=False) - - logger.debug('Found all relevant files for scanning %s', {'path': path, 'branch': branch}) - scan_documents( - context, documents_to_scan, is_git_diff=False, scan_parameters=get_scan_parameters(context, path) - ) - except Exception as e: - _handle_exception(context, e) - - -@click.command(short_help='Scan all the commits history in this git repository.') -@click.argument('path', nargs=1, type=click.Path(exists=True, resolve_path=True), required=True) -@click.option( - '--commit_range', - '-r', - help='Scan a commit range in this git repository, by default cycode scans all commit history (example: HEAD~1)', - type=click.STRING, - default='--all', - required=False, -) -@click.pass_context -def scan_repository_commit_history(context: click.Context, path: str, commit_range: str) -> None: - try: - 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: - _handle_exception(context, e) - - -def scan_commit_range( - context: click.Context, path: str, commit_range: str, max_commits_count: Optional[int] = None -) -> None: - scan_type = context.obj['scan_type'] - - progress_bar = context.obj['progress_bar'] - progress_bar.start() - - if scan_type not in consts.COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES: - raise click.ClickException(f'Commit range scanning for {str.upper(scan_type)} is not supported') - - if scan_type == consts.SCA_SCAN_TYPE: - return scan_sca_commit_range(context, path, commit_range) - - documents_to_scan = [] - commit_ids_to_scan = [] - - repo = Repo(path) - total_commits_count = int(repo.git.rev_list('--count', commit_range)) - logger.debug(f'Calculating diffs for {total_commits_count} commits in the commit range {commit_range}') - - progress_bar.set_section_length(ScanProgressBarSection.PREPARE_LOCAL_FILES, total_commits_count) - - scanned_commits_count = 0 - for commit in repo.iter_commits(rev=commit_range): - if _does_reach_to_max_commits_to_scan_limit(commit_ids_to_scan, max_commits_count): - logger.debug(f'Reached to max commits to scan count. Going to scan only {max_commits_count} last commits') - progress_bar.update(ScanProgressBarSection.PREPARE_LOCAL_FILES, total_commits_count - scanned_commits_count) - break - - progress_bar.update(ScanProgressBarSection.PREPARE_LOCAL_FILES) - - commit_id = commit.hexsha - commit_ids_to_scan.append(commit_id) - parent = commit.parents[0] if commit.parents else NULL_TREE - diff = commit.diff(parent, create_patch=True, R=True) - commit_documents_to_scan = [] - for blob in diff: - blob_path = get_path_by_os(os.path.join(path, get_diff_file_path(blob))) - commit_documents_to_scan.append( - Document( - path=blob_path, - content=blob.diff.decode('UTF-8', errors='replace'), - is_git_diff_format=True, - unique_id=commit_id, - ) - ) - - logger.debug( - 'Found all relevant files in commit %s', - {'path': path, 'commit_range': commit_range, 'commit_id': commit_id}, - ) - - documents_to_scan.extend(exclude_irrelevant_documents_to_scan(scan_type, commit_documents_to_scan)) - scanned_commits_count += 1 - - logger.debug('List of commit ids to scan, %s', {'commit_ids': commit_ids_to_scan}) - logger.debug('Starting to scan commit range (It may take a few minutes)') - - scan_documents(context, documents_to_scan, is_git_diff=True, is_commit_range=True) - return None - - -@click.command( - short_help='Execute scan in a CI environment which relies on the ' - 'CYCODE_TOKEN and CYCODE_REPO_LOCATION environment variables' -) -@click.pass_context -def scan_ci(context: click.Context) -> None: - scan_commit_range(context, path=os.getcwd(), commit_range=get_commit_range()) - - -@click.command(short_help='Scan the files in the path provided in the command.') -@click.argument('path', nargs=1, type=click.Path(exists=True, resolve_path=True), required=True) -@click.pass_context -def scan_path(context: click.Context, path: str) -> None: - progress_bar = context.obj['progress_bar'] - progress_bar.start() - - logger.debug('Starting path scan process, %s', {'path': path}) - scan_disk_files(context, path) - - -@click.command(short_help='Use this command to scan any content that was not committed yet.') -@click.argument('ignored_args', nargs=-1, type=click.UNPROCESSED) -@click.pass_context -def pre_commit_scan(context: click.Context, ignored_args: List[str]) -> None: - scan_type = context.obj['scan_type'] - - progress_bar = context.obj['progress_bar'] - progress_bar.start() - - if scan_type == consts.SCA_SCAN_TYPE: - scan_sca_pre_commit(context) - return - - diff_files = Repo(os.getcwd()).index.diff('HEAD', create_patch=True, R=True) - - progress_bar.set_section_length(ScanProgressBarSection.PREPARE_LOCAL_FILES, len(diff_files)) - - documents_to_scan = [] - for file in diff_files: - progress_bar.update(ScanProgressBarSection.PREPARE_LOCAL_FILES) - documents_to_scan.append(Document(get_path_by_os(get_diff_file_path(file)), get_diff_file_content(file))) - - documents_to_scan = exclude_irrelevant_documents_to_scan(scan_type, documents_to_scan) - scan_documents(context, documents_to_scan, is_git_diff=True) - - -@click.command(short_help='Use this command to scan commits on the server side before pushing them to the repository.') -@click.argument('ignored_args', nargs=-1, type=click.UNPROCESSED) -@click.pass_context -def pre_receive_scan(context: click.Context, ignored_args: List[str]) -> None: - try: - 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') - - if should_skip_pre_receive_scan(): - logger.info( - 'A scan has been skipped as per your request.' - ' Please note that this may leave your system vulnerable to secrets that have not been detected' - ) - return - - if is_verbose_mode_requested_in_pre_receive_scan(): - enable_verbose_mode(context) - logger.debug('Verbose mode enabled, all log levels will be displayed') - - command_scan_type = context.info_name - timeout = configuration_manager.get_pre_receive_command_timeout(command_scan_type) - with TimeoutAfter(timeout): - if scan_type not in consts.COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES: - raise click.ClickException(f'Commit range scanning for {scan_type.upper()} is not supported') - - branch_update_details = parse_pre_receive_input() - commit_range = calculate_pre_receive_commit_range(branch_update_details) - if not commit_range: - logger.info( - 'No new commits found for pushed branch, %s', {'branch_update_details': branch_update_details} - ) - return - - max_commits_to_scan = configuration_manager.get_pre_receive_max_commits_to_scan_count(command_scan_type) - scan_commit_range(context, os.getcwd(), commit_range, max_commits_count=max_commits_to_scan) - perform_post_pre_receive_scan_actions(context) - except Exception as e: - _handle_exception(context, e) - - def scan_sca_pre_commit(context: click.Context) -> None: scan_type = context.obj['scan_type'] scan_parameters = get_default_scan_parameters(context) @@ -312,7 +92,7 @@ def scan_disk_files(context: click.Context, path: str) -> None: perform_pre_scan_documents_actions(context, scan_type, documents) scan_documents(context, documents, scan_parameters=scan_parameters) except Exception as e: - _handle_exception(context, e) + handle_scan_exception(context, e) def set_issue_detected_by_scan_results(context: click.Context, scan_results: List[LocalScanResult]) -> None: @@ -349,7 +129,7 @@ def _scan_batch_thread_func(batch: List[Document]) -> Tuple[str, CliError, Local scan_completed = True except Exception as e: - error = _handle_exception(context, e, return_exception=True) + error = handle_scan_exception(context, e, return_exception=True) error_message = str(e) if local_scan_result: @@ -384,6 +164,69 @@ def _scan_batch_thread_func(batch: List[Document]) -> Tuple[str, CliError, Local return _scan_batch_thread_func +def scan_commit_range( + context: click.Context, path: str, commit_range: str, max_commits_count: Optional[int] = None +) -> None: + scan_type = context.obj['scan_type'] + + progress_bar = context.obj['progress_bar'] + progress_bar.start() + + if scan_type not in consts.COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES: + raise click.ClickException(f'Commit range scanning for {str.upper(scan_type)} is not supported') + + if scan_type == consts.SCA_SCAN_TYPE: + return scan_sca_commit_range(context, path, commit_range) + + documents_to_scan = [] + commit_ids_to_scan = [] + + repo = Repo(path) + total_commits_count = int(repo.git.rev_list('--count', commit_range)) + logger.debug(f'Calculating diffs for {total_commits_count} commits in the commit range {commit_range}') + + progress_bar.set_section_length(ScanProgressBarSection.PREPARE_LOCAL_FILES, total_commits_count) + + scanned_commits_count = 0 + for commit in repo.iter_commits(rev=commit_range): + if _does_reach_to_max_commits_to_scan_limit(commit_ids_to_scan, max_commits_count): + logger.debug(f'Reached to max commits to scan count. Going to scan only {max_commits_count} last commits') + progress_bar.update(ScanProgressBarSection.PREPARE_LOCAL_FILES, total_commits_count - scanned_commits_count) + break + + progress_bar.update(ScanProgressBarSection.PREPARE_LOCAL_FILES) + + commit_id = commit.hexsha + commit_ids_to_scan.append(commit_id) + parent = commit.parents[0] if commit.parents else NULL_TREE + diff = commit.diff(parent, create_patch=True, R=True) + commit_documents_to_scan = [] + for blob in diff: + blob_path = get_path_by_os(os.path.join(path, get_diff_file_path(blob))) + commit_documents_to_scan.append( + Document( + path=blob_path, + content=blob.diff.decode('UTF-8', errors='replace'), + is_git_diff_format=True, + unique_id=commit_id, + ) + ) + + logger.debug( + 'Found all relevant files in commit %s', + {'path': path, 'commit_range': commit_range, 'commit_id': commit_id}, + ) + + documents_to_scan.extend(exclude_irrelevant_documents_to_scan(scan_type, commit_documents_to_scan)) + scanned_commits_count += 1 + + logger.debug('List of commit ids to scan, %s', {'commit_ids': commit_ids_to_scan}) + logger.debug('Starting to scan commit range (It may take a few minutes)') + + scan_documents(context, documents_to_scan, is_git_diff=True, is_commit_range=True) + return None + + def scan_documents( context: click.Context, documents_to_scan: List[Document], @@ -472,7 +315,7 @@ def scan_commit_range_documents( scan_completed = True except Exception as e: - _handle_exception(context, e) + handle_scan_exception(context, e) error_message = str(e) zip_file_size = from_commit_zipped_documents.size + to_commit_zipped_documents.size @@ -827,75 +670,6 @@ def _get_document_by_file_name( return None -def _handle_exception(context: click.Context, e: Exception, *, return_exception: bool = False) -> Optional[CliError]: - context.obj['did_fail'] = True - - if context.obj['verbose']: - click.secho(f'Error: {traceback.format_exc()}', fg='red') - - errors: CliErrors = { - custom_exceptions.NetworkError: CliError( - soft_fail=True, - code='cycode_error', - message='Cycode was unable to complete this scan. ' - 'Please try again by executing the `cycode scan` command', - ), - custom_exceptions.ScanAsyncError: CliError( - soft_fail=True, - code='scan_error', - message='Cycode was unable to complete this scan. ' - 'Please try again by executing the `cycode scan` command', - ), - custom_exceptions.HttpUnauthorizedError: CliError( - soft_fail=True, - code='auth_error', - message='Unable to authenticate to Cycode, your token is either invalid or has expired. ' - 'Please re-generate your token and reconfigure it by running the `cycode configure` command', - ), - custom_exceptions.ZipTooLargeError: CliError( - soft_fail=True, - code='zip_too_large_error', - message='The path you attempted to scan exceeds the current maximum scanning size cap (10MB). ' - 'Please try ignoring irrelevant paths using the `cycode ignore --by-path` command ' - 'and execute the scan again', - ), - custom_exceptions.TfplanKeyError: CliError( - soft_fail=True, - code='key_error', - message=f'\n{e!s}\n' - 'A crucial field is missing in your terraform plan file. ' - 'Please make sure that your file is well formed ' - 'and execute the scan again', - ), - InvalidGitRepositoryError: CliError( - soft_fail=False, - code='invalid_git_error', - message='The path you supplied does not correlate to a git repository. ' - 'If you still wish to scan this path, use: `cycode scan path `', - ), - } - - if type(e) in errors: - error = errors[type(e)] - - if error.soft_fail is True: - context.obj['soft_fail'] = True - - if return_exception: - return error - - ConsolePrinter(context).print_error(error) - return None - - if return_exception: - return CliError(code='unknown_error', message=str(e)) - - if isinstance(e, click.ClickException): - raise e - - raise click.ClickException(str(e)) - - def _report_scan_status( cycode_client: 'ScanClient', scan_type: str, diff --git a/cycode/cli/commands/scan/commit_history/__init__.py b/cycode/cli/commands/scan/commit_history/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/commands/scan/commit_history/commit_history_command.py b/cycode/cli/commands/scan/commit_history/commit_history_command.py new file mode 100644 index 00000000..f7db9404 --- /dev/null +++ b/cycode/cli/commands/scan/commit_history/commit_history_command.py @@ -0,0 +1,24 @@ +import click + +from cycode.cli.commands.scan.code_scanner import scan_commit_range +from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception +from cycode.cyclient import logger + + +@click.command(short_help='Scan all the commits history in this git repository.') +@click.argument('path', nargs=1, type=click.Path(exists=True, resolve_path=True), required=True) +@click.option( + '--commit_range', + '-r', + help='Scan a commit range in this git repository, by default cycode scans all commit history (example: HEAD~1)', + type=click.STRING, + default='--all', + required=False, +) +@click.pass_context +def commit_history_command(context: click.Context, path: str, commit_range: str) -> None: + try: + 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: + handle_scan_exception(context, e) diff --git a/cycode/cli/commands/scan/path/__init__.py b/cycode/cli/commands/scan/path/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/commands/scan/path/path_command.py b/cycode/cli/commands/scan/path/path_command.py new file mode 100644 index 00000000..7098016e --- /dev/null +++ b/cycode/cli/commands/scan/path/path_command.py @@ -0,0 +1,15 @@ +import click + +from cycode.cli.commands.scan.code_scanner import scan_disk_files +from cycode.cyclient import logger + + +@click.command(short_help='Scan the files in the path provided in the command.') +@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: + progress_bar = context.obj['progress_bar'] + progress_bar.start() + + logger.debug('Starting path scan process, %s', {'path': path}) + scan_disk_files(context, path) diff --git a/cycode/cli/commands/scan/pre_commit/__init__.py b/cycode/cli/commands/scan/pre_commit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/commands/scan/pre_commit/pre_commit_command.py b/cycode/cli/commands/scan/pre_commit/pre_commit_command.py new file mode 100644 index 00000000..a758f0f5 --- /dev/null +++ b/cycode/cli/commands/scan/pre_commit/pre_commit_command.py @@ -0,0 +1,44 @@ +import os +from typing import List + +import click +from git import Repo + +from cycode.cli import consts +from cycode.cli.commands.scan.code_scanner import scan_documents, scan_sca_pre_commit +from cycode.cli.files_collector.excluder import exclude_irrelevant_documents_to_scan +from cycode.cli.files_collector.repository_documents import ( + get_diff_file_content, + get_diff_file_path, +) +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import ( + get_path_by_os, +) +from cycode.cli.utils.progress_bar import ScanProgressBarSection + + +@click.command(short_help='Use this command to scan any content that was not committed yet.') +@click.argument('ignored_args', nargs=-1, type=click.UNPROCESSED) +@click.pass_context +def pre_commit_command(context: click.Context, ignored_args: List[str]) -> None: + scan_type = context.obj['scan_type'] + + progress_bar = context.obj['progress_bar'] + progress_bar.start() + + if scan_type == consts.SCA_SCAN_TYPE: + scan_sca_pre_commit(context) + return + + diff_files = Repo(os.getcwd()).index.diff('HEAD', create_patch=True, R=True) + + progress_bar.set_section_length(ScanProgressBarSection.PREPARE_LOCAL_FILES, len(diff_files)) + + documents_to_scan = [] + for file in diff_files: + progress_bar.update(ScanProgressBarSection.PREPARE_LOCAL_FILES) + documents_to_scan.append(Document(get_path_by_os(get_diff_file_path(file)), get_diff_file_content(file))) + + documents_to_scan = exclude_irrelevant_documents_to_scan(scan_type, documents_to_scan) + scan_documents(context, documents_to_scan, is_git_diff=True) diff --git a/cycode/cli/commands/scan/pre_receive/__init__.py b/cycode/cli/commands/scan/pre_receive/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/commands/scan/pre_receive/pre_receive_command.py b/cycode/cli/commands/scan/pre_receive/pre_receive_command.py new file mode 100644 index 00000000..71cd82c1 --- /dev/null +++ b/cycode/cli/commands/scan/pre_receive/pre_receive_command.py @@ -0,0 +1,62 @@ +import os +from typing import List + +import click + +from cycode.cli import consts +from cycode.cli.commands.scan.code_scanner import ( + enable_verbose_mode, + is_verbose_mode_requested_in_pre_receive_scan, + parse_pre_receive_input, + perform_post_pre_receive_scan_actions, + scan_commit_range, + should_skip_pre_receive_scan, +) +from cycode.cli.config import configuration_manager +from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception +from cycode.cli.files_collector.repository_documents import ( + calculate_pre_receive_commit_range, +) +from cycode.cli.utils.task_timer import TimeoutAfter +from cycode.cyclient import logger + + +@click.command(short_help='Use this command to scan commits on the server side before pushing them to the repository.') +@click.argument('ignored_args', nargs=-1, type=click.UNPROCESSED) +@click.pass_context +def pre_receive_command(context: click.Context, ignored_args: List[str]) -> None: + try: + 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') + + if should_skip_pre_receive_scan(): + logger.info( + 'A scan has been skipped as per your request.' + ' Please note that this may leave your system vulnerable to secrets that have not been detected' + ) + return + + if is_verbose_mode_requested_in_pre_receive_scan(): + enable_verbose_mode(context) + logger.debug('Verbose mode enabled, all log levels will be displayed') + + command_scan_type = context.info_name + timeout = configuration_manager.get_pre_receive_command_timeout(command_scan_type) + with TimeoutAfter(timeout): + if scan_type not in consts.COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES: + raise click.ClickException(f'Commit range scanning for {scan_type.upper()} is not supported') + + branch_update_details = parse_pre_receive_input() + commit_range = calculate_pre_receive_commit_range(branch_update_details) + if not commit_range: + logger.info( + 'No new commits found for pushed branch, %s', {'branch_update_details': branch_update_details} + ) + return + + max_commits_to_scan = configuration_manager.get_pre_receive_max_commits_to_scan_count(command_scan_type) + scan_commit_range(context, os.getcwd(), commit_range, max_commits_count=max_commits_to_scan) + perform_post_pre_receive_scan_actions(context) + except Exception as e: + handle_scan_exception(context, e) diff --git a/cycode/cli/commands/scan/repository/__init__.py b/cycode/cli/commands/scan/repository/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/commands/scan/repository/repisotiry_command.py b/cycode/cli/commands/scan/repository/repisotiry_command.py new file mode 100644 index 00000000..38aab411 --- /dev/null +++ b/cycode/cli/commands/scan/repository/repisotiry_command.py @@ -0,0 +1,60 @@ +import os + +import click + +from cycode.cli import consts +from cycode.cli.commands.scan.code_scanner import get_scan_parameters, scan_documents +from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception +from cycode.cli.files_collector.excluder import exclude_irrelevant_documents_to_scan +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.utils.path_utils import get_path_by_os +from cycode.cli.utils.progress_bar import ScanProgressBarSection +from cycode.cyclient import logger + + +@click.command(short_help='Scan the git repository including its history.') +@click.argument('path', nargs=1, type=click.Path(exists=True, resolve_path=True), required=True) +@click.option( + '--branch', + '-b', + default=None, + help='Branch to scan, if not set scanning the default branch', + type=str, + required=False, +) +@click.pass_context +def repisotiry_command(context: click.Context, path: str, branch: str) -> None: + try: + logger.debug('Starting repository scan process, %s', {'path': path, 'branch': branch}) + + scan_type = context.obj['scan_type'] + monitor = context.obj.get('monitor') + if monitor and scan_type != consts.SCA_SCAN_TYPE: + raise click.ClickException('Monitor flag is currently supported for SCA scan type only') + + progress_bar = context.obj['progress_bar'] + progress_bar.start() + + file_entries = list(get_git_repository_tree_file_entries(path, branch)) + progress_bar.set_section_length(ScanProgressBarSection.PREPARE_LOCAL_FILES, len(file_entries)) + + documents_to_scan = [] + for file in file_entries: + # FIXME(MarshalX): probably file could be tree or submodule too. we expect blob only + progress_bar.update(ScanProgressBarSection.PREPARE_LOCAL_FILES) + + file_path = file.path if monitor else get_path_by_os(os.path.join(path, file.path)) + documents_to_scan.append(Document(file_path, file.data_stream.read().decode('UTF-8', errors='replace'))) + + documents_to_scan = exclude_irrelevant_documents_to_scan(scan_type, documents_to_scan) + + perform_pre_scan_documents_actions(context, scan_type, documents_to_scan, is_git_diff=False) + + logger.debug('Found all relevant files for scanning %s', {'path': path, 'branch': branch}) + scan_documents( + context, documents_to_scan, is_git_diff=False, scan_parameters=get_scan_parameters(context, path) + ) + except Exception as e: + handle_scan_exception(context, e) diff --git a/cycode/cli/commands/scan/scan_ci/__init__.py b/cycode/cli/commands/scan/scan_ci/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/ci_integrations.py b/cycode/cli/commands/scan/scan_ci/ci_integrations.py similarity index 100% rename from cycode/cli/ci_integrations.py rename to cycode/cli/commands/scan/scan_ci/ci_integrations.py diff --git a/cycode/cli/commands/scan/scan_ci/scan_ci_command.py b/cycode/cli/commands/scan/scan_ci/scan_ci_command.py new file mode 100644 index 00000000..594aad63 --- /dev/null +++ b/cycode/cli/commands/scan/scan_ci/scan_ci_command.py @@ -0,0 +1,15 @@ +import os + +import click + +from cycode.cli.commands.scan.code_scanner import scan_commit_range +from cycode.cli.commands.scan.scan_ci.ci_integrations import get_commit_range + + +@click.command( + short_help='Execute scan in a CI environment which relies on the ' + 'CYCODE_TOKEN and CYCODE_REPO_LOCATION environment variables' +) +@click.pass_context +def scan_ci_command(context: click.Context) -> None: + 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 new file mode 100644 index 00000000..920b8be2 --- /dev/null +++ b/cycode/cli/commands/scan/scan_command.py @@ -0,0 +1,159 @@ +import sys +from typing import List + +import click + +from cycode.cli.commands.scan.commit_history.commit_history_command import commit_history_command +from cycode.cli.commands.scan.path.path_command import path_command +from cycode.cli.commands.scan.pre_commit.pre_commit_command import pre_commit_command +from cycode.cli.commands.scan.pre_receive.pre_receive_command import pre_receive_command +from cycode.cli.commands.scan.repository.repisotiry_command import repisotiry_command +from cycode.cli.config import config +from cycode.cli.consts import ( + ISSUE_DETECTED_STATUS_CODE, + NO_ISSUES_STATUS_CODE, + SCA_SKIP_RESTORE_DEPENDENCIES_FLAG, +) +from cycode.cli.models import Severity +from cycode.cli.utils import scan_utils +from cycode.cli.utils.get_api_client import get_scan_cycode_client + + +@click.group( + commands={ + 'repository': repisotiry_command, + 'commit_history': commit_history_command, + 'path': path_command, + 'pre_commit': pre_commit_command, + 'pre_receive': pre_receive_command, + }, + short_help='Scan the content for Secrets/IaC/SCA/SAST violations. ' + 'You`ll need to specify which scan type to perform: ci/commit_history/path/repository/etc.', +) +@click.option( + '--scan-type', + '-t', + default='secret', + help='Specify the type of scan you wish to execute (the default is Secrets)', + type=click.Choice(config['scans']['supported_scans']), +) +@click.option( + '--secret', + default=None, + help='Specify a Cycode client secret for this specific scan execution.', + type=str, + required=False, +) +@click.option( + '--client-id', + default=None, + help='Specify a Cycode client ID for this specific scan execution.', + type=str, + required=False, +) +@click.option( + '--show-secret', is_flag=True, default=False, help='Show Secrets in plain text.', type=bool, required=False +) +@click.option( + '--soft-fail', + is_flag=True, + default=False, + help='Run the scan without failing; always return a non-error status code.', + type=bool, + required=False, +) +@click.option( + '--severity-threshold', + default=None, + help='Show violations only for the specified level or higher (supported for SCA scan types only).', + type=click.Choice([e.name for e in Severity]), + required=False, +) +@click.option( + '--sca-scan', + default=None, + help='Specify the type of SCA scan you wish to execute (the default is both).', + multiple=True, + type=click.Choice(config['scans']['supported_sca_scans']), +) +@click.option( + '--monitor', + is_flag=True, + default=False, + help='Used for SCA scan types only; when specified, the scan results are recorded in the Discovery module.', + type=bool, + required=False, +) +@click.option( + '--report', + is_flag=True, + default=False, + help='When specified, generates a violations report. A link to the report will be displayed in the console output.', + type=bool, + required=False, +) +@click.option( + f'--{SCA_SKIP_RESTORE_DEPENDENCIES_FLAG}', + is_flag=True, + default=False, + help='When specified, Cycode will not run restore command. Will scan direct dependencies ONLY!', + type=bool, + required=False, +) +@click.pass_context +def scan_command( + context: click.Context, + scan_type: str, + secret: str, + client_id: str, + show_secret: bool, + soft_fail: bool, + severity_threshold: str, + sca_scan: List[str], + monitor: bool, + report: bool, + no_restore: bool, +) -> int: + """Scans for Secrets, IaC, SCA or SAST violations.""" + if show_secret: + context.obj['show_secret'] = show_secret + else: + context.obj['show_secret'] = config['result_printer']['default']['show_secret'] + + if soft_fail: + context.obj['soft_fail'] = soft_fail + else: + context.obj['soft_fail'] = config['soft_fail'] + + context.obj['client'] = get_scan_cycode_client(client_id, secret, not context.obj['show_secret']) + context.obj['scan_type'] = scan_type + context.obj['severity_threshold'] = severity_threshold + context.obj['monitor'] = monitor + context.obj['report'] = report + context.obj[SCA_SKIP_RESTORE_DEPENDENCIES_FLAG] = no_restore + + _sca_scan_to_context(context, sca_scan) + + return 1 + + +def _sca_scan_to_context(context: click.Context, sca_scan_user_selected: List[str]) -> None: + for sca_scan_option_selected in sca_scan_user_selected: + context.obj[sca_scan_option_selected] = True + + +@scan_command.result_callback() +@click.pass_context +def finalize(context: click.Context, *_, **__) -> None: + progress_bar = context.obj.get('progress_bar') + if progress_bar: + progress_bar.stop() + + if context.obj['soft_fail']: + sys.exit(0) + + exit_code = NO_ISSUES_STATUS_CODE + if scan_utils.is_scan_failed(context): + exit_code = ISSUE_DETECTED_STATUS_CODE + + sys.exit(exit_code) diff --git a/cycode/cli/commands/version/__init__.py b/cycode/cli/commands/version/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/commands/version/version_command.py b/cycode/cli/commands/version/version_command.py new file mode 100644 index 00000000..55755e24 --- /dev/null +++ b/cycode/cli/commands/version/version_command.py @@ -0,0 +1,22 @@ +import json + +import click + +from cycode import __version__ +from cycode.cli.consts import PROGRAM_NAME + + +@click.command(short_help='Show the CLI version and exit.') +@click.pass_context +def version_command(context: click.Context) -> None: + output = context.obj['output'] + + prog = PROGRAM_NAME + ver = __version__ + + message = f'{prog}, version {ver}' + if output == 'json': + message = json.dumps({'name': prog, 'version': ver}) + + click.echo(message, color=context.color) + context.exit() diff --git a/cycode/cli/commands/report/sbom/handle_errors.py b/cycode/cli/exceptions/handle_report_sbom_errors.py similarity index 100% rename from cycode/cli/commands/report/sbom/handle_errors.py rename to cycode/cli/exceptions/handle_report_sbom_errors.py diff --git a/cycode/cli/exceptions/handle_scan_errors.py b/cycode/cli/exceptions/handle_scan_errors.py new file mode 100644 index 00000000..1ee026f8 --- /dev/null +++ b/cycode/cli/exceptions/handle_scan_errors.py @@ -0,0 +1,80 @@ +import traceback +from typing import Optional + +import click +from git import InvalidGitRepositoryError + +from cycode.cli.exceptions import custom_exceptions +from cycode.cli.models import CliError, CliErrors +from cycode.cli.printers import ConsolePrinter + + +def handle_scan_exception( + context: click.Context, e: Exception, *, return_exception: bool = False +) -> Optional[CliError]: + context.obj['did_fail'] = True + + if context.obj['verbose']: + click.secho(f'Error: {traceback.format_exc()}', fg='red') + + errors: CliErrors = { + custom_exceptions.NetworkError: CliError( + soft_fail=True, + code='cycode_error', + message='Cycode was unable to complete this scan. ' + 'Please try again by executing the `cycode scan` command', + ), + custom_exceptions.ScanAsyncError: CliError( + soft_fail=True, + code='scan_error', + message='Cycode was unable to complete this scan. ' + 'Please try again by executing the `cycode scan` command', + ), + custom_exceptions.HttpUnauthorizedError: CliError( + soft_fail=True, + code='auth_error', + message='Unable to authenticate to Cycode, your token is either invalid or has expired. ' + 'Please re-generate your token and reconfigure it by running the `cycode configure` command', + ), + custom_exceptions.ZipTooLargeError: CliError( + soft_fail=True, + code='zip_too_large_error', + message='The path you attempted to scan exceeds the current maximum scanning size cap (10MB). ' + 'Please try ignoring irrelevant paths using the `cycode ignore --by-path` command ' + 'and execute the scan again', + ), + custom_exceptions.TfplanKeyError: CliError( + soft_fail=True, + code='key_error', + message=f'\n{e!s}\n' + 'A crucial field is missing in your terraform plan file. ' + 'Please make sure that your file is well formed ' + 'and execute the scan again', + ), + InvalidGitRepositoryError: CliError( + soft_fail=False, + code='invalid_git_error', + message='The path you supplied does not correlate to a git repository. ' + 'If you still wish to scan this path, use: `cycode scan path `', + ), + } + + if type(e) in errors: + error = errors[type(e)] + + if error.soft_fail is True: + context.obj['soft_fail'] = True + + if return_exception: + return error + + ConsolePrinter(context).print_error(error) + return None + + if return_exception: + return CliError(code='unknown_error', message=str(e)) + + if isinstance(e, click.ClickException): + raise e + + raise click.ClickException(str(e)) diff --git a/cycode/cli/main.py b/cycode/cli/main.py index 082cca3a..b27c98e2 100644 --- a/cycode/cli/main.py +++ b/cycode/cli/main.py @@ -1,253 +1,4 @@ -import json -import logging -import sys -from typing import List, Optional - -import click - -from cycode import __version__ -from cycode.cli import code_scanner -from cycode.cli.auth.auth_command import authenticate -from cycode.cli.commands.configure.configure_command import configure_command -from cycode.cli.commands.ignore.ignore_command import ignore_command -from cycode.cli.commands.report.report_command import report_command -from cycode.cli.config import config -from cycode.cli.consts import ( - CLI_CONTEXT_SETTINGS, - ISSUE_DETECTED_STATUS_CODE, - NO_ISSUES_STATUS_CODE, - PROGRAM_NAME, - SCA_SKIP_RESTORE_DEPENDENCIES_FLAG, -) -from cycode.cli.models import Severity -from cycode.cli.user_settings.configuration_manager import ConfigurationManager -from cycode.cli.utils import scan_utils -from cycode.cli.utils.get_api_client import get_scan_cycode_client -from cycode.cli.utils.progress_bar import SCAN_PROGRESS_BAR_SECTIONS, get_progress_bar -from cycode.cyclient.config import set_logging_level -from cycode.cyclient.cycode_client_base import CycodeClientBase -from cycode.cyclient.models import UserAgentOptionScheme - - -@click.group( - commands={ - 'repository': code_scanner.scan_repository, - 'commit_history': code_scanner.scan_repository_commit_history, - 'path': code_scanner.scan_path, - 'pre_commit': code_scanner.pre_commit_scan, - 'pre_receive': code_scanner.pre_receive_scan, - }, - short_help='Scan the content for Secrets/IaC/SCA/SAST violations. ' - 'You`ll need to specify which scan type to perform: ci/commit_history/path/repository/etc.', -) -@click.option( - '--scan-type', - '-t', - default='secret', - help='Specify the type of scan you wish to execute (the default is Secrets)', - type=click.Choice(config['scans']['supported_scans']), -) -@click.option( - '--secret', - default=None, - help='Specify a Cycode client secret for this specific scan execution.', - type=str, - required=False, -) -@click.option( - '--client-id', - default=None, - help='Specify a Cycode client ID for this specific scan execution.', - type=str, - required=False, -) -@click.option( - '--show-secret', is_flag=True, default=False, help='Show Secrets in plain text.', type=bool, required=False -) -@click.option( - '--soft-fail', - is_flag=True, - default=False, - help='Run the scan without failing; always return a non-error status code.', - type=bool, - required=False, -) -@click.option( - '--severity-threshold', - default=None, - help='Show violations only for the specified level or higher (supported for SCA scan types only).', - type=click.Choice([e.name for e in Severity]), - required=False, -) -@click.option( - '--sca-scan', - default=None, - help='Specify the type of SCA scan you wish to execute (the default is both).', - multiple=True, - type=click.Choice(config['scans']['supported_sca_scans']), -) -@click.option( - '--monitor', - is_flag=True, - default=False, - help='Used for SCA scan types only; when specified, the scan results are recorded in the Discovery module.', - type=bool, - required=False, -) -@click.option( - '--report', - is_flag=True, - default=False, - help='When specified, generates a violations report. A link to the report will be displayed in the console output.', - type=bool, - required=False, -) -@click.option( - f'--{SCA_SKIP_RESTORE_DEPENDENCIES_FLAG}', - is_flag=True, - default=False, - help='When specified, Cycode will not run restore command. Will scan direct dependencies ONLY!', - type=bool, - required=False, -) -@click.pass_context -def code_scan( - context: click.Context, - scan_type: str, - secret: str, - client_id: str, - show_secret: bool, - soft_fail: bool, - severity_threshold: str, - sca_scan: List[str], - monitor: bool, - report: bool, - no_restore: bool, -) -> int: - """Scans for Secrets, IaC, SCA or SAST violations.""" - if show_secret: - context.obj['show_secret'] = show_secret - else: - context.obj['show_secret'] = config['result_printer']['default']['show_secret'] - - if soft_fail: - context.obj['soft_fail'] = soft_fail - else: - context.obj['soft_fail'] = config['soft_fail'] - - context.obj['client'] = get_scan_cycode_client(client_id, secret, not context.obj['show_secret']) - context.obj['scan_type'] = scan_type - context.obj['severity_threshold'] = severity_threshold - context.obj['monitor'] = monitor - context.obj['report'] = report - context.obj[SCA_SKIP_RESTORE_DEPENDENCIES_FLAG] = no_restore - - _sca_scan_to_context(context, sca_scan) - - return 1 - - -@code_scan.result_callback() -@click.pass_context -def finalize(context: click.Context, *_, **__) -> None: - progress_bar = context.obj.get('progress_bar') - if progress_bar: - progress_bar.stop() - - if context.obj['soft_fail']: - sys.exit(0) - - exit_code = NO_ISSUES_STATUS_CODE - if _should_fail_scan(context): - exit_code = ISSUE_DETECTED_STATUS_CODE - - sys.exit(exit_code) - - -@click.command(short_help='Show the CLI version and exit.') -@click.pass_context -def version(context: click.Context) -> None: - output = context.obj['output'] - - prog = PROGRAM_NAME - ver = __version__ - - message = f'{prog}, version {ver}' - if output == 'json': - message = json.dumps({'name': prog, 'version': ver}) - - click.echo(message, color=context.color) - context.exit() - - -@click.group( - commands={ - 'scan': code_scan, - 'report': report_command, - 'configure': configure_command, - 'ignore': ignore_command, - 'auth': authenticate, - 'version': version, - }, - context_settings=CLI_CONTEXT_SETTINGS, -) -@click.option( - '--verbose', - '-v', - is_flag=True, - default=False, - help='Show detailed logs.', -) -@click.option( - '--no-progress-meter', - is_flag=True, - default=False, - help='Do not show the progress meter.', -) -@click.option( - '--output', - '-o', - default='text', - help='Specify the output type (the default is text).', - type=click.Choice(['text', 'json', 'table']), -) -@click.option( - '--user-agent', - default=None, - help='Characteristic JSON object that lets servers identify the application.', - type=str, -) -@click.pass_context -def main_cli( - context: click.Context, verbose: bool, no_progress_meter: bool, output: str, user_agent: Optional[str] -) -> None: - context.ensure_object(dict) - configuration_manager = ConfigurationManager() - - verbose = verbose or configuration_manager.get_verbose_flag() - context.obj['verbose'] = verbose - if verbose: - set_logging_level(logging.DEBUG) - - context.obj['output'] = output - if output == 'json': - no_progress_meter = True - - context.obj['progress_bar'] = get_progress_bar(hidden=no_progress_meter, sections=SCAN_PROGRESS_BAR_SECTIONS) - - if user_agent: - user_agent_option = UserAgentOptionScheme().loads(user_agent) - CycodeClientBase.enrich_user_agent(user_agent_option.user_agent_suffix) - - -def _should_fail_scan(context: click.Context) -> bool: - return scan_utils.is_scan_failed(context) - - -def _sca_scan_to_context(context: click.Context, sca_scan_user_selected: List[str]) -> None: - for sca_scan_option_selected in sca_scan_user_selected: - context.obj[sca_scan_option_selected] = True - +from cycode.cli.commands.main_cli import main_cli if __name__ == '__main__': main_cli() diff --git a/tests/cli/commands/__init__.py b/tests/cli/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/commands/configure/__init__.py b/tests/cli/commands/configure/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/test_configure_command.py b/tests/cli/commands/configure/test_configure_command.py similarity index 100% rename from tests/cli/test_configure_command.py rename to tests/cli/commands/configure/test_configure_command.py diff --git a/tests/cli/commands/scan/__init__.py b/tests/cli/commands/scan/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/test_code_scanner.py b/tests/cli/commands/scan/test_code_scanner.py similarity index 55% rename from tests/cli/test_code_scanner.py rename to tests/cli/commands/scan/test_code_scanner.py index f4fe4f69..c993958c 100644 --- a/tests/cli/test_code_scanner.py +++ b/tests/cli/commands/scan/test_code_scanner.py @@ -1,76 +1,10 @@ import os -from typing import TYPE_CHECKING - -import click -import pytest -from click import ClickException -from git import InvalidGitRepositoryError -from requests import Response from cycode.cli import consts -from cycode.cli.code_scanner import _handle_exception -from cycode.cli.exceptions import custom_exceptions from cycode.cli.files_collector.excluder import _is_file_relevant_for_sca_scan from cycode.cli.files_collector.path_documents import _generate_document from cycode.cli.models import Document -if TYPE_CHECKING: - from _pytest.monkeypatch import MonkeyPatch - - -@pytest.fixture() -def ctx() -> click.Context: - return click.Context(click.Command('path'), obj={'verbose': False, 'output': 'text'}) - - -@pytest.mark.parametrize( - 'exception, expected_soft_fail', - [ - (custom_exceptions.NetworkError(400, 'msg', Response()), True), - (custom_exceptions.ScanAsyncError('msg'), True), - (custom_exceptions.HttpUnauthorizedError('msg', Response()), True), - (custom_exceptions.ZipTooLargeError(1000), True), - (custom_exceptions.TfplanKeyError('msg'), True), - (InvalidGitRepositoryError(), None), - ], -) -def test_handle_exception_soft_fail( - ctx: click.Context, exception: custom_exceptions.CycodeError, expected_soft_fail: bool -) -> None: - with ctx: - _handle_exception(ctx, exception) - - assert ctx.obj.get('did_fail') is True - assert ctx.obj.get('soft_fail') is expected_soft_fail - - -def test_handle_exception_unhandled_error(ctx: click.Context) -> None: - with ctx, pytest.raises(ClickException): - _handle_exception(ctx, ValueError('test')) - - assert ctx.obj.get('did_fail') is True - assert ctx.obj.get('soft_fail') is None - - -def test_handle_exception_click_error(ctx: click.Context) -> None: - with ctx, pytest.raises(ClickException): - _handle_exception(ctx, click.ClickException('test')) - - assert ctx.obj.get('did_fail') is True - assert ctx.obj.get('soft_fail') is None - - -def test_handle_exception_verbose(monkeypatch: 'MonkeyPatch') -> None: - ctx = click.Context(click.Command('path'), obj={'verbose': True, 'output': 'text'}) - - def mock_secho(msg: str, *_, **__) -> None: - assert 'Error:' in msg - - monkeypatch.setattr(click, 'secho', mock_secho) - - with ctx, pytest.raises(ClickException): - _handle_exception(ctx, ValueError('test')) - def test_is_file_relevant_for_sca_scan() -> None: path = os.path.join('some_package', 'node_modules', 'package.json') diff --git a/tests/cli/test_main.py b/tests/cli/commands/test_main_command.py similarity index 96% rename from tests/cli/test_main.py rename to tests/cli/commands/test_main_command.py index 3f41ed6a..d74a2c40 100644 --- a/tests/cli/test_main.py +++ b/tests/cli/commands/test_main_command.py @@ -6,7 +6,7 @@ import responses from click.testing import CliRunner -from cycode.cli.main import main_cli +from cycode.cli.commands.main_cli import main_cli from tests.conftest import CLI_ENV_VARS, TEST_FILES_PATH, ZIP_CONTENT_PATH from tests.cyclient.mocked_responses.scan_client import mock_scan_responses from tests.cyclient.test_scan_client import get_zipped_file_scan_response, get_zipped_file_scan_url diff --git a/tests/cli/exceptions/__init__.py b/tests/cli/exceptions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/exceptions/test_handle_scan_errors.py b/tests/cli/exceptions/test_handle_scan_errors.py new file mode 100644 index 00000000..7d63802b --- /dev/null +++ b/tests/cli/exceptions/test_handle_scan_errors.py @@ -0,0 +1,67 @@ +from typing import TYPE_CHECKING + +import click +import pytest +from click import ClickException +from git import InvalidGitRepositoryError +from requests import Response + +from cycode.cli.exceptions import custom_exceptions +from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception + +if TYPE_CHECKING: + from _pytest.monkeypatch import MonkeyPatch + + +@pytest.fixture() +def ctx() -> click.Context: + return click.Context(click.Command('path'), obj={'verbose': False, 'output': 'text'}) + + +@pytest.mark.parametrize( + 'exception, expected_soft_fail', + [ + (custom_exceptions.NetworkError(400, 'msg', Response()), True), + (custom_exceptions.ScanAsyncError('msg'), True), + (custom_exceptions.HttpUnauthorizedError('msg', Response()), True), + (custom_exceptions.ZipTooLargeError(1000), True), + (custom_exceptions.TfplanKeyError('msg'), True), + (InvalidGitRepositoryError(), None), + ], +) +def test_handle_exception_soft_fail( + ctx: click.Context, exception: custom_exceptions.CycodeError, expected_soft_fail: bool +) -> None: + with ctx: + handle_scan_exception(ctx, exception) + + assert ctx.obj.get('did_fail') is True + assert ctx.obj.get('soft_fail') is expected_soft_fail + + +def test_handle_exception_unhandled_error(ctx: click.Context) -> None: + with ctx, pytest.raises(ClickException): + handle_scan_exception(ctx, ValueError('test')) + + assert ctx.obj.get('did_fail') is True + assert ctx.obj.get('soft_fail') is None + + +def test_handle_exception_click_error(ctx: click.Context) -> None: + with ctx, pytest.raises(ClickException): + handle_scan_exception(ctx, click.ClickException('test')) + + assert ctx.obj.get('did_fail') is True + assert ctx.obj.get('soft_fail') is None + + +def test_handle_exception_verbose(monkeypatch: 'MonkeyPatch') -> None: + ctx = click.Context(click.Command('path'), obj={'verbose': True, 'output': 'text'}) + + def mock_secho(msg: str, *_, **__) -> None: + assert 'Error:' in msg + + monkeypatch.setattr(click, 'secho', mock_secho) + + with ctx, pytest.raises(ClickException): + handle_scan_exception(ctx, ValueError('test')) diff --git a/tests/cli/files_collector/iac/__init__.py b/tests/cli/files_collector/iac/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/helpers/test_tf_content_generator.py b/tests/cli/files_collector/iac/test_tf_content_generator.py similarity index 100% rename from tests/cli/helpers/test_tf_content_generator.py rename to tests/cli/files_collector/iac/test_tf_content_generator.py diff --git a/tests/cyclient/test_auth_client.py b/tests/cyclient/test_auth_client.py index 0a34d3b2..51618e3c 100644 --- a/tests/cyclient/test_auth_client.py +++ b/tests/cyclient/test_auth_client.py @@ -3,6 +3,7 @@ import responses from requests import Timeout +from cycode.cli.commands.auth.auth_manager import AuthManager from cycode.cli.exceptions.custom_exceptions import CycodeError from cycode.cyclient.auth_client import AuthClient from cycode.cyclient.models import ( @@ -14,16 +15,12 @@ @pytest.fixture(scope='module') def code_challenge() -> str: - from cycode.cli.auth.auth_manager import AuthManager - code_challenge, _ = AuthManager()._generate_pkce_code_pair() return code_challenge @pytest.fixture(scope='module') def code_verifier() -> str: - from cycode.cli.auth.auth_manager import AuthManager - _, code_verifier = AuthManager()._generate_pkce_code_pair() return code_verifier diff --git a/tests/cyclient/test_scan_client.py b/tests/cyclient/test_scan_client.py index 6df7b544..69d657b2 100644 --- a/tests/cyclient/test_scan_client.py +++ b/tests/cyclient/test_scan_client.py @@ -8,10 +8,10 @@ from requests import Timeout from requests.exceptions import ProxyError -from cycode.cli.code_scanner import zip_documents from cycode.cli.config import config from cycode.cli.exceptions.custom_exceptions import CycodeError, HttpUnauthorizedError from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip +from cycode.cli.files_collector.zip_documents import zip_documents from cycode.cli.models import Document from cycode.cyclient.scan_client import ScanClient from tests.conftest import ZIP_CONTENT_PATH From 0366b40d8dde7e29b7942df97dbdbcaa3ca6d914 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Thu, 7 Dec 2023 11:20:55 +0100 Subject: [PATCH 051/257] CM-29953 - Scanning many paths in one CLI run (#183) --- .../commands/report/sbom/path/path_command.py | 6 +-- cycode/cli/commands/scan/code_scanner.py | 37 ++++++++++++------- cycode/cli/commands/scan/path/path_command.py | 10 +++-- ...otiry_command.py => repository_command.py} | 4 +- .../commands/scan/scan_ci/scan_ci_command.py | 2 + cycode/cli/commands/scan/scan_command.py | 6 +-- cycode/cli/files_collector/path_documents.py | 18 +++++---- 7 files changed, 50 insertions(+), 33 deletions(-) rename cycode/cli/commands/scan/repository/{repisotiry_command.py => repository_command.py} (95%) diff --git a/cycode/cli/commands/report/sbom/path/path_command.py b/cycode/cli/commands/report/sbom/path/path_command.py index 323eb62b..8e88bd10 100644 --- a/cycode/cli/commands/report/sbom/path/path_command.py +++ b/cycode/cli/commands/report/sbom/path/path_command.py @@ -5,7 +5,7 @@ from cycode.cli import consts 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.files_collector.path_documents import get_relevant_document +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.utils.get_api_client import get_report_cycode_client @@ -28,8 +28,8 @@ def path_command(context: click.Context, path: str) -> None: report_execution_id = -1 try: - documents = get_relevant_document( - progress_bar, SbomReportProgressBarSection.PREPARE_LOCAL_FILES, consts.SCA_SCAN_TYPE, path + documents = get_relevant_documents( + progress_bar, SbomReportProgressBarSection.PREPARE_LOCAL_FILES, consts.SCA_SCAN_TYPE, (path,) ) # TODO(MarshalX): combine perform_pre_scan_documents_actions with get_relevant_document. # unhardcode usage of context in perform_pre_scan_documents_actions diff --git a/cycode/cli/commands/scan/code_scanner.py b/cycode/cli/commands/scan/code_scanner.py index be1014de..8ac6c05b 100644 --- a/cycode/cli/commands/scan/code_scanner.py +++ b/cycode/cli/commands/scan/code_scanner.py @@ -16,7 +16,7 @@ from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception from cycode.cli.files_collector.excluder import exclude_irrelevant_documents_to_scan from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip -from cycode.cli.files_collector.path_documents import get_relevant_document +from cycode.cli.files_collector.path_documents import get_relevant_documents from cycode.cli.files_collector.repository_documents import ( get_commit_range_modified_documents, get_diff_file_path, @@ -68,7 +68,7 @@ def scan_sca_commit_range(context: click.Context, path: str, commit_range: str) scan_type = context.obj['scan_type'] progress_bar = context.obj['progress_bar'] - scan_parameters = get_scan_parameters(context, path) + scan_parameters = get_scan_parameters(context, (path,)) from_commit_rev, to_commit_rev = parse_commit_range(commit_range, path) from_commit_documents, to_commit_documents = get_commit_range_modified_documents( progress_bar, ScanProgressBarSection.PREPARE_LOCAL_FILES, path, from_commit_rev, to_commit_rev @@ -82,13 +82,13 @@ def scan_sca_commit_range(context: click.Context, path: str, commit_range: str) scan_commit_range_documents(context, from_commit_documents, to_commit_documents, scan_parameters=scan_parameters) -def scan_disk_files(context: click.Context, path: str) -> None: - scan_parameters = get_scan_parameters(context, path) +def scan_disk_files(context: click.Context, paths: Tuple[str]) -> None: + scan_parameters = get_scan_parameters(context, paths) scan_type = context.obj['scan_type'] progress_bar = context.obj['progress_bar'] try: - documents = get_relevant_document(progress_bar, ScanProgressBarSection.PREPARE_LOCAL_FILES, scan_type, path) + documents = get_relevant_documents(progress_bar, ScanProgressBarSection.PREPARE_LOCAL_FILES, scan_type, paths) perform_pre_scan_documents_actions(context, scan_type, documents) scan_documents(context, documents, scan_parameters=scan_parameters) except Exception as e: @@ -535,22 +535,31 @@ def get_default_scan_parameters(context: click.Context) -> dict: } -def get_scan_parameters(context: click.Context, path: str) -> dict: +def get_scan_parameters(context: click.Context, paths: Tuple[str]) -> dict: scan_parameters = get_default_scan_parameters(context) - remote_url = try_get_git_remote_url(path) + + if len(paths) != 1: + # ignore remote url if multiple paths are provided + return scan_parameters + + remote_url = try_get_git_remote_url(paths[0]) if remote_url: - # TODO(MarshalX): remove hardcode + # TODO(MarshalX): remove hardcode in context context.obj['remote_url'] = remote_url - scan_parameters.update(remote_url) + scan_parameters.update( + { + 'remote_url': remote_url, + } + ) + return scan_parameters -def try_get_git_remote_url(path: str) -> Optional[dict]: +def try_get_git_remote_url(path: str) -> Optional[str]: try: - git_remote_url = Repo(path).remotes[0].config_reader.get('url') - return { - 'remote_url': git_remote_url, - } + remote_url = Repo(path).remotes[0].config_reader.get('url') + logger.debug(f'Found Git remote URL "{remote_url}" in path "{path}"') + return remote_url except Exception as e: logger.debug('Failed to get git remote URL. %s', {'exception_message': str(e)}) return None diff --git a/cycode/cli/commands/scan/path/path_command.py b/cycode/cli/commands/scan/path/path_command.py index 7098016e..63182577 100644 --- a/cycode/cli/commands/scan/path/path_command.py +++ b/cycode/cli/commands/scan/path/path_command.py @@ -1,3 +1,5 @@ +from typing import Tuple + import click from cycode.cli.commands.scan.code_scanner import scan_disk_files @@ -5,11 +7,11 @@ @click.command(short_help='Scan the files in the path provided in the command.') -@click.argument('path', nargs=1, type=click.Path(exists=True, resolve_path=True), required=True) +@click.argument('paths', nargs=-1, type=click.Path(exists=True, resolve_path=True), required=True) @click.pass_context -def path_command(context: click.Context, path: str) -> None: +def path_command(context: click.Context, paths: Tuple[str]) -> None: progress_bar = context.obj['progress_bar'] progress_bar.start() - logger.debug('Starting path scan process, %s', {'path': path}) - scan_disk_files(context, path) + logger.debug('Starting path scan process, %s', {'paths': paths}) + scan_disk_files(context, paths) diff --git a/cycode/cli/commands/scan/repository/repisotiry_command.py b/cycode/cli/commands/scan/repository/repository_command.py similarity index 95% rename from cycode/cli/commands/scan/repository/repisotiry_command.py rename to cycode/cli/commands/scan/repository/repository_command.py index 38aab411..cf560c26 100644 --- a/cycode/cli/commands/scan/repository/repisotiry_command.py +++ b/cycode/cli/commands/scan/repository/repository_command.py @@ -25,7 +25,7 @@ required=False, ) @click.pass_context -def repisotiry_command(context: click.Context, path: str, branch: str) -> None: +def repository_command(context: click.Context, path: str, branch: str) -> None: try: logger.debug('Starting repository scan process, %s', {'path': path, 'branch': branch}) @@ -54,7 +54,7 @@ def repisotiry_command(context: click.Context, path: str, branch: str) -> None: logger.debug('Found all relevant files for scanning %s', {'path': path, 'branch': branch}) scan_documents( - context, documents_to_scan, is_git_diff=False, scan_parameters=get_scan_parameters(context, path) + context, documents_to_scan, is_git_diff=False, scan_parameters=get_scan_parameters(context, (path,)) ) except Exception as e: handle_scan_exception(context, e) 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 594aad63..70383422 100644 --- a/cycode/cli/commands/scan/scan_ci/scan_ci_command.py +++ b/cycode/cli/commands/scan/scan_ci/scan_ci_command.py @@ -5,6 +5,8 @@ from cycode.cli.commands.scan.code_scanner import scan_commit_range from cycode.cli.commands.scan.scan_ci.ci_integrations import get_commit_range +# This command is not finished yet. It is not used in the codebase. + @click.command( short_help='Execute scan in a CI environment which relies on the ' diff --git a/cycode/cli/commands/scan/scan_command.py b/cycode/cli/commands/scan/scan_command.py index 920b8be2..a53f501b 100644 --- a/cycode/cli/commands/scan/scan_command.py +++ b/cycode/cli/commands/scan/scan_command.py @@ -7,7 +7,7 @@ from cycode.cli.commands.scan.path.path_command import path_command from cycode.cli.commands.scan.pre_commit.pre_commit_command import pre_commit_command from cycode.cli.commands.scan.pre_receive.pre_receive_command import pre_receive_command -from cycode.cli.commands.scan.repository.repisotiry_command import repisotiry_command +from cycode.cli.commands.scan.repository.repository_command import repository_command from cycode.cli.config import config from cycode.cli.consts import ( ISSUE_DETECTED_STATUS_CODE, @@ -21,14 +21,14 @@ @click.group( commands={ - 'repository': repisotiry_command, + 'repository': repository_command, 'commit_history': commit_history_command, 'path': path_command, 'pre_commit': pre_commit_command, 'pre_receive': pre_receive_command, }, short_help='Scan the content for Secrets/IaC/SCA/SAST violations. ' - 'You`ll need to specify which scan type to perform: ci/commit_history/path/repository/etc.', + 'You`ll need to specify which scan type to perform: commit_history/path/repository/etc.', ) @click.option( '--scan-type', diff --git a/cycode/cli/files_collector/path_documents.py b/cycode/cli/files_collector/path_documents.py index a0df5ac0..1d16e4f9 100644 --- a/cycode/cli/files_collector/path_documents.py +++ b/cycode/cli/files_collector/path_documents.py @@ -1,5 +1,5 @@ import os -from typing import TYPE_CHECKING, Iterable, List +from typing import TYPE_CHECKING, Iterable, List, Tuple import pathspec @@ -48,9 +48,13 @@ def _get_relevant_files_in_path(path: str, exclude_patterns: Iterable[str]) -> L def _get_relevant_files( - progress_bar: 'BaseProgressBar', progress_bar_section: 'ProgressBarSection', scan_type: str, path: str + progress_bar: 'BaseProgressBar', progress_bar_section: 'ProgressBarSection', scan_type: str, paths: Tuple[str] ) -> List[str]: - all_files_to_scan = _get_relevant_files_in_path(path=path, exclude_patterns=['**/.git/**', '**/.cycode/**']) + all_files_to_scan = [] + for path in paths: + all_files_to_scan.extend( + _get_relevant_files_in_path(path=path, exclude_patterns=['**/.git/**', '**/.cycode/**']) + ) # we are double the progress bar section length because we are going to process the files twice # first time to get the file list with respect of excluded patterns (excluding takes seconds to execute) @@ -70,7 +74,7 @@ def _get_relevant_files( progress_bar.set_section_length(progress_bar_section, progress_bar_section_len) logger.debug( - 'Found all relevant files for scanning %s', {'path': path, 'file_to_scan_count': len(relevant_files_to_scan)} + 'Found all relevant files for scanning %s', {'paths': paths, 'file_to_scan_count': len(relevant_files_to_scan)} ) return relevant_files_to_scan @@ -89,15 +93,15 @@ def _handle_tfplan_file(file: str, content: str, is_git_diff: bool) -> Document: return Document(document_name, tf_content, is_git_diff) -def get_relevant_document( +def get_relevant_documents( progress_bar: 'BaseProgressBar', progress_bar_section: 'ProgressBarSection', scan_type: str, - path: str, + paths: Tuple[str], *, is_git_diff: bool = False, ) -> List[Document]: - relevant_files = _get_relevant_files(progress_bar, progress_bar_section, scan_type, path) + relevant_files = _get_relevant_files(progress_bar, progress_bar_section, scan_type, paths) documents: List[Document] = [] for file in relevant_files: From 081eda60734e2e3fa2994e9a733f6092042fd7b3 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 13 Dec 2023 10:46:37 +0100 Subject: [PATCH 052/257] CM-30139 - Add Company Guidelines (#184) --- cycode/cli/commands/scan/code_scanner.py | 31 ++++++++++ cycode/cli/printers/text_printer.py | 14 ++++- cycode/cyclient/models.py | 36 ++++++++++++ cycode/cyclient/scan_client.py | 57 ++++++++++++++++++- .../data/detection_rules.json | 20 +++++++ .../cyclient/mocked_responses/scan_client.py | 15 +++++ 6 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 tests/cyclient/mocked_responses/data/detection_rules.json diff --git a/cycode/cli/commands/scan/code_scanner.py b/cycode/cli/commands/scan/code_scanner.py index 8ac6c05b..7cd596d0 100644 --- a/cycode/cli/commands/scan/code_scanner.py +++ b/cycode/cli/commands/scan/code_scanner.py @@ -99,6 +99,35 @@ def set_issue_detected_by_scan_results(context: click.Context, scan_results: Lis set_issue_detected(context, any(scan_result.issue_detected for scan_result in scan_results)) +def _enrich_scan_result_with_data_from_detection_rules( + cycode_client: 'ScanClient', scan_type: str, scan_result: ZippedFileScanResult +) -> None: + # TODO(MarshalX): remove scan_type arg after migration to new backend filter + if scan_type != consts.SECRET_SCAN_TYPE: + # not yet + return + + detection_rule_ids = set() + for detections_per_file in scan_result.detections_per_file: + for detection in detections_per_file.detections: + detection_rule_ids.add(detection.detection_rule_id) + + detection_rules = cycode_client.get_detection_rules(scan_type, detection_rule_ids) + detection_rules_by_id = {detection_rule.detection_rule_id: detection_rule for detection_rule in detection_rules} + + for detections_per_file in scan_result.detections_per_file: + for detection in detections_per_file.detections: + detection_rule = detection_rules_by_id.get(detection.detection_rule_id) + if not detection_rule: + # we want to make sure that BE returned it. better to not map data instead of failed scan + continue + + # TODO(MarshalX): here we can also map severity without migrating secrets to async flow + + # detection_details never was typed properly. so not a problem for now + detection.detection_details['custom_remediation_guidelines'] = detection_rule.custom_remediation_guidelines + + def _get_scan_documents_thread_func( context: click.Context, is_git_diff: bool, is_commit_range: bool, scan_parameters: dict ) -> Callable[[List[Document]], Tuple[str, CliError, LocalScanResult]]: @@ -123,6 +152,8 @@ def _scan_batch_thread_func(batch: List[Document]) -> Tuple[str, CliError, Local cycode_client, zipped_documents, scan_type, scan_id, is_git_diff, is_commit_range, scan_parameters ) + _enrich_scan_result_with_data_from_detection_rules(cycode_client, scan_type, scan_result) + local_scan_result = create_local_scan_result( scan_result, batch, command_scan_type, scan_type, severity_threshold ) diff --git a/cycode/cli/printers/text_printer.py b/cycode/cli/printers/text_printer.py index 821e755f..2bbab6a3 100644 --- a/cycode/cli/printers/text_printer.py +++ b/cycode/cli/printers/text_printer.py @@ -68,6 +68,7 @@ def _print_detection_summary( self, detection: Detection, document_path: str, scan_id: str, report_url: Optional[str] ) -> None: detection_name = detection.type if self.scan_type == SECRET_SCAN_TYPE else detection.message + detection_name_styled = click.style(detection_name, fg='bright_red', bold=True) detection_sha = detection.detection_details.get('sha512') detection_sha_message = f'\nSecret SHA: {detection_sha}' if detection_sha else '' @@ -78,10 +79,19 @@ def _print_detection_summary( detection_commit_id = detection.detection_details.get('commit_id') detection_commit_id_message = f'\nCommit SHA: {detection_commit_id}' if detection_commit_id else '' + company_guidelines = detection.detection_details.get('custom_remediation_guidelines') + company_guidelines_message = f'\nCompany Guideline: {company_guidelines}' if company_guidelines else '' + click.echo( - f'⛔ Found issue of type: {click.style(detection_name, fg="bright_red", bold=True)} ' + f'⛔ ' + f'Found issue of type: {detection_name_styled} ' f'(rule ID: {detection.detection_rule_id}) in file: {click.format_filename(document_path)} ' - f'{detection_sha_message}{scan_id_message}{report_url_message}{detection_commit_id_message} ⛔' + f'{detection_sha_message}' + f'{scan_id_message}' + f'{report_url_message}' + f'{detection_commit_id_message}' + f'{company_guidelines_message}' + f' ⛔' ) def _print_detection_code_segment(self, detection: Detection, document: Document, code_segment_size: int) -> None: diff --git a/cycode/cyclient/models.py b/cycode/cyclient/models.py index 0401e3fb..aef9748d 100644 --- a/cycode/cyclient/models.py +++ b/cycode/cyclient/models.py @@ -404,3 +404,39 @@ class Meta: @post_load def build_dto(self, data: Dict[str, Any], **_) -> SbomReport: return SbomReport(**data) + + +@dataclass +class ClassificationData: + severity: str + + +class ClassificationDataSchema(Schema): + class Meta: + unknown = EXCLUDE + + severity = fields.String() + + @post_load + def build_dto(self, data: Dict[str, Any], **_) -> ClassificationData: + return ClassificationData(**data) + + +@dataclass +class DetectionRule: + classification_data: List[ClassificationData] + detection_rule_id: str + custom_remediation_guidelines: Optional[str] = None + + +class DetectionRuleSchema(Schema): + class Meta: + unknown = EXCLUDE + + classification_data = fields.Nested(ClassificationDataSchema, many=True) + detection_rule_id = fields.String() + custom_remediation_guidelines = fields.String(allow_none=True) + + @post_load + def build_dto(self, data: Dict[str, Any], **_) -> DetectionRule: + return DetectionRule(**data) diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index 50ee0e79..30036fcd 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -1,8 +1,10 @@ import json -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING, List, Optional, Set, Union from requests import Response +from cycode.cli import consts +from cycode.cli.exceptions.custom_exceptions import CycodeError from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip from cycode.cyclient import models from cycode.cyclient.cycode_client_base import CycodeClientBase @@ -20,6 +22,7 @@ def __init__( self.SCAN_CONTROLLER_PATH = 'api/v1/scan' self.DETECTIONS_SERVICE_CONTROLLER_PATH = 'api/v1/detections' + self.POLICIES_SERVICE_CONTROLLER_PATH_V3 = 'api/v3/policies' self._hide_response_log = hide_response_log @@ -95,6 +98,58 @@ def get_scan_details(self, scan_id: str) -> models.ScanDetailsResponse: response = self.scan_cycode_client.get(url_path=self.get_scan_details_path(scan_id)) return models.ScanDetailsResponseSchema().load(response.json()) + def get_detection_rules_path(self) -> str: + return ( + f'{self.scan_config.get_detections_prefix()}/' + f'{self.POLICIES_SERVICE_CONTROLLER_PATH_V3}/' + f'detection_rules' + ) + + @staticmethod + def _get_policy_type_by_scan_type(scan_type: str) -> str: + scan_type_to_policy_type = { + consts.INFRA_CONFIGURATION_SCAN_TYPE: 'IaC', + consts.SCA_SCAN_TYPE: 'SCA', + consts.SECRET_SCAN_TYPE: 'SecretDetection', + consts.SAST_SCAN_TYPE: 'SAST', + } + + if scan_type not in scan_type_to_policy_type: + raise CycodeError('Invalid scan type') + + return scan_type_to_policy_type[scan_type] + + @staticmethod + def _filter_detection_rules_by_ids( + detection_rules: List[models.DetectionRule], detection_rules_ids: Union[Set[str], List[str]] + ) -> List[models.DetectionRule]: + ids = set(detection_rules_ids) # cast to set to perform faster search + return [rule for rule in detection_rules if rule.detection_rule_id in ids] + + @staticmethod + def parse_detection_rules_response(response: Response) -> List[models.DetectionRule]: + return models.DetectionRuleSchema().load(response.json(), many=True) + + def get_detection_rules( + self, scan_type: str, detection_rules_ids: Union[Set[str], List[str]] + ) -> List[models.DetectionRule]: + # TODO(MarshalX): use filter by list of IDs instead of policy_type when BE will be ready + params = { + 'include_hidden': False, + 'include_only_enabled_detection_rules': True, + 'page_number': 0, + 'page_size': 5000, + 'policy_types_v2': self._get_policy_type_by_scan_type(scan_type), + } + response = self.scan_cycode_client.get( + url_path=self.get_detection_rules_path(), + params=params, + hide_response_content_log=self._hide_response_log, + ) + + # we are filtering rules by ids in-place for smooth migration when backend will be ready + return self._filter_detection_rules_by_ids(self.parse_detection_rules_response(response), detection_rules_ids) + def get_scan_detections_path(self) -> str: return f'{self.scan_config.get_detections_prefix()}/{self.DETECTIONS_SERVICE_CONTROLLER_PATH}' diff --git a/tests/cyclient/mocked_responses/data/detection_rules.json b/tests/cyclient/mocked_responses/data/detection_rules.json new file mode 100644 index 00000000..b221951a --- /dev/null +++ b/tests/cyclient/mocked_responses/data/detection_rules.json @@ -0,0 +1,20 @@ +[ + { + "classification_data": [ + { + "severity": "High", + "classification_rule_id": "e4e826bd-5820-4cc9-ae5b-bbfceb500a21" + } + ], + "detection_rule_id": "26ab3395-2522-4061-a50a-c69c2d622ca1" + }, + { + "classification_data": [ + { + "severity": "High", + "classification_rule_id": "e4e826bd-5820-4cc9-ae5b-bbfceb500a22" + } + ], + "detection_rule_id": "12345678-aea1-4304-a6e9-012345678901" + } +] diff --git a/tests/cyclient/mocked_responses/scan_client.py b/tests/cyclient/mocked_responses/scan_client.py index dff10003..de1613c9 100644 --- a/tests/cyclient/mocked_responses/scan_client.py +++ b/tests/cyclient/mocked_responses/scan_client.py @@ -146,6 +146,19 @@ def get_report_scan_status_response(url: str) -> responses.Response: return responses.Response(method=responses.POST, url=url, status=200) +def get_detection_rules_url(scan_client: ScanClient) -> str: + api_url = scan_client.scan_cycode_client.api_url + service_url = scan_client.get_detection_rules_path() + return f'{api_url}/{service_url}' + + +def get_detection_rules_response(url: str) -> responses.Response: + with open(MOCKED_RESPONSES_PATH.joinpath('detection_rules.json'), encoding='UTF-8') as f: + json_response = json.load(f) + + return responses.Response(method=responses.GET, url=url, json=json_response, status=200) + + def mock_scan_async_responses( responses_module: responses, scan_type: str, scan_client: ScanClient, scan_id: UUID, zip_content_path: Path ) -> None: @@ -153,6 +166,7 @@ def mock_scan_async_responses( get_zipped_file_scan_async_response(get_zipped_file_scan_async_url(scan_type, scan_client), scan_id) ) responses_module.add(get_scan_details_response(get_scan_details_url(scan_id, scan_client), scan_id)) + responses_module.add(get_detection_rules_response(get_detection_rules_url(scan_client))) responses_module.add(get_scan_detections_count_response(get_scan_detections_count_url(scan_client))) responses_module.add(get_scan_detections_response(get_scan_detections_url(scan_client), scan_id, zip_content_path)) responses_module.add(get_report_scan_status_response(get_report_scan_status_url(scan_type, scan_id, scan_client))) @@ -164,4 +178,5 @@ def mock_scan_responses( responses_module.add( get_zipped_file_scan_response(get_zipped_file_scan_url(scan_type, scan_client), zip_content_path) ) + responses_module.add(get_detection_rules_response(get_detection_rules_url(scan_client))) responses_module.add(get_report_scan_status_response(get_report_scan_status_url(scan_type, scan_id, scan_client))) From ed45fad26e21f8da655d5fc0c100f3a0e648116e Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 13 Dec 2023 12:13:11 +0100 Subject: [PATCH 053/257] CM-30183 - Add severity for secret detections (#185) --- cycode/cli/commands/scan/code_scanner.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cycode/cli/commands/scan/code_scanner.py b/cycode/cli/commands/scan/code_scanner.py index 7cd596d0..753f1410 100644 --- a/cycode/cli/commands/scan/code_scanner.py +++ b/cycode/cli/commands/scan/code_scanner.py @@ -122,7 +122,11 @@ def _enrich_scan_result_with_data_from_detection_rules( # we want to make sure that BE returned it. better to not map data instead of failed scan continue - # TODO(MarshalX): here we can also map severity without migrating secrets to async flow + if detection_rule.classification_data: + # it's fine to take the first one, because: + # - for "secrets" and "iac" there is only one classification rule per detection rule + # - for "sca" and "sast" we get severity from detection service + detection.severity = detection_rule.classification_data[0].severity # detection_details never was typed properly. so not a problem for now detection.detection_details['custom_remediation_guidelines'] = detection_rule.custom_remediation_guidelines From d5014e3ac3cdd23bc93ea8c1f62a54fd3ce2480a Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Thu, 21 Dec 2023 14:59:33 +0100 Subject: [PATCH 054/257] CM-30405 - Add building of executable CLI in the onedir mode and ARM macOS (#187) --- .github/workflows/build_executable.yml | 91 ++++++++++++++++++-------- .github/workflows/tests_full.yml | 3 +- poetry.lock | 26 ++++---- pyinstaller.spec | 60 +++++++---------- pyproject.toml | 2 +- 5 files changed, 102 insertions(+), 80 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index a0a0e706..0de165b6 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -11,10 +11,17 @@ permissions: jobs: build: + name: Build on ${{ matrix.os }} (${{ matrix.mode }} mode) strategy: fail-fast: false matrix: - os: [ ubuntu-20.04, macos-11, windows-2019 ] + os: [ ubuntu-20.04, macos-11, macos-13-xlarge, windows-2019 ] + mode: [ 'onefile', 'onedir' ] + exclude: + - os: ubuntu-20.04 + mode: onedir + - os: windows-2019 + mode: onedir runs-on: ${{ matrix.os }} @@ -48,17 +55,17 @@ jobs: git checkout $LATEST_TAG echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV - - name: Set up Python 3.7 + - name: Set up Python 3.12 uses: actions/setup-python@v4 with: - python-version: '3.7' + python-version: '3.12' - name: Load cached Poetry setup id: cached-poetry uses: actions/cache@v3 with: path: ~/.local - key: poetry-${{ matrix.os }}-0 # increment to reset cache + key: poetry-${{ matrix.os }}-1 # increment to reset cache - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' @@ -70,15 +77,9 @@ jobs: run: echo "$HOME/.local/bin" >> $GITHUB_PATH - name: Install dependencies - run: poetry install + run: poetry install --without dev,test - - name: Build executable - run: poetry run pyinstaller pyinstaller.spec - - - name: Test executable - run: ./dist/cycode version - - - name: Sign macOS executable + - name: Import macOS signing certificate if: runner.os == 'macOS' env: APPLE_CERT: ${{ secrets.APPLE_CERT }} @@ -100,8 +101,25 @@ jobs: security import $CERTIFICATE_PATH -P "$APPLE_CERT_PWD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH security list-keychain -d user -s $KEYCHAIN_PATH - # sign executable - codesign --deep --force --options=runtime --entitlements entitlements.plist --sign "$APPLE_CERT_NAME" --timestamp dist/cycode + - name: Build executable (onefile) + if: matrix.mode == 'onefile' + env: + APPLE_CERT_NAME: ${{ secrets.APPLE_CERT_NAME }} + run: | + poetry run pyinstaller pyinstaller.spec + echo "PATH_TO_CYCODE_CLI_EXECUTABLE=dist/cycode-cli" >> $GITHUB_ENV + + - name: Build executable (onedir) + if: matrix.mode == 'onedir' + env: + CYCODE_ONEDIR_MODE: 1 + APPLE_CERT_NAME: ${{ secrets.APPLE_CERT_NAME }} + run: | + poetry run pyinstaller pyinstaller.spec + echo "PATH_TO_CYCODE_CLI_EXECUTABLE=dist/cycode-cli/cycode-cli" >> $GITHUB_ENV + + - name: Test executable + run: time $PATH_TO_CYCODE_CLI_EXECUTABLE version - name: Notarize macOS executable if: runner.os == 'macOS' @@ -114,17 +132,20 @@ jobs: xcrun notarytool store-credentials "notarytool-profile" --apple-id "$APPLE_NOTARIZATION_EMAIL" --team-id "$APPLE_NOTARIZATION_TEAM_ID" --password "$APPLE_NOTARIZATION_PWD" # create zip file (notarization does not support binaries) - ditto -c -k --keepParent dist/cycode notarization.zip + ditto -c -k --keepParent dist/cycode-cli notarization.zip # notarize app (this will take a while) xcrun notarytool submit notarization.zip --keychain-profile "notarytool-profile" --wait - # we can't staple the app because it's executable. we should only staple app bundles like .dmg - # xcrun stapler staple dist/cycode + # we can't staple the app because it's executable - name: Test macOS signed executable if: runner.os == 'macOS' - run: ./dist/cycode version + run: | + time $PATH_TO_CYCODE_CLI_EXECUTABLE version + + # verify signature + codesign -dv --verbose=4 $PATH_TO_CYCODE_CLI_EXECUTABLE - name: Import cert for Windows and setup envs if: runner.os == 'Windows' @@ -155,39 +176,57 @@ jobs: smksp_cert_sync.exe :: sign executable - signtool.exe sign /sha1 %SM_CODE_SIGNING_CERT_SHA1_HASH% /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 ".\dist\cycode.exe" + signtool.exe sign /sha1 %SM_CODE_SIGNING_CERT_SHA1_HASH% /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 ".\dist\cycode-cli.exe" - name: Test Windows signed executable if: runner.os == 'Windows' shell: cmd run: | :: call executable and expect correct output - .\dist\cycode.exe version + .\dist\cycode-cli.exe version :: verify signature - signtool.exe verify /v /pa ".\dist\cycode.exe" + signtool.exe verify /v /pa ".\dist\cycode-cli.exe" - name: Prepare files on Windows if: runner.os == 'Windows' run: | echo "ARTIFACT_NAME=cycode-win" >> $GITHUB_ENV - mv dist/cycode.exe dist/cycode-win.exe + mv dist/cycode-cli.exe dist/cycode-win.exe powershell -Command "(Get-FileHash -Algorithm SHA256 dist/cycode-win.exe).Hash" > sha256 head -c 64 sha256 > dist/cycode-win.exe.sha256 - - name: Prepare files on macOS - if: runner.os == 'macOS' + - name: Prepare files on Intel macOS (onefile) + if: runner.os == 'macOS' && runner.arch == 'X64' && matrix.mode == 'onefile' run: | echo "ARTIFACT_NAME=cycode-mac" >> $GITHUB_ENV - mv dist/cycode dist/cycode-mac + mv dist/cycode-cli dist/cycode-mac shasum -a 256 dist/cycode-mac > sha256 head -c 64 sha256 > dist/cycode-mac.sha256 + - name: Prepare files on Apple Silicon macOS (onefile) + if: runner.os == 'macOS' && runner.arch == 'ARM64' && matrix.mode == 'onefile' + run: | + echo "ARTIFACT_NAME=cycode-mac-arm" >> $GITHUB_ENV + mv dist/cycode-cli dist/cycode-mac-arm + shasum -a 256 dist/cycode-mac-arm > sha256 + head -c 64 sha256 > dist/cycode-mac-arm.sha256 + + - name: Prepare files on Intel macOS (onedir) + if: runner.os == 'macOS' && runner.arch == 'X64' && matrix.mode == 'onedir' + run: | + echo "ARTIFACT_NAME=cycode-mac-onedir" >> $GITHUB_ENV + + - name: Prepare files on Apple Silicon macOS (onedir) + if: runner.os == 'macOS' && runner.arch == 'ARM64' && matrix.mode == 'onedir' + run: | + echo "ARTIFACT_NAME=cycode-mac-arm-onedir" >> $GITHUB_ENV + - name: Prepare files on Linux if: runner.os == 'Linux' run: | echo "ARTIFACT_NAME=cycode-linux" >> $GITHUB_ENV - mv dist/cycode dist/cycode-linux + mv dist/cycode-cli dist/cycode-linux sha256sum dist/cycode-linux > sha256 head -c 64 sha256 > dist/cycode-linux.sha256 diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index aaa58d89..8225b0c3 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -64,10 +64,9 @@ jobs: run: poetry install - name: Run executable test - if: runner.os == 'Linux' run: | poetry run pyinstaller pyinstaller.spec - ./dist/cycode version + ./dist/cycode-cli version - name: Run pytest run: poetry run pytest diff --git a/poetry.lock b/poetry.lock index 46d5ffcd..e4e96a4d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -92,13 +92,13 @@ uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "certifi" -version = "2023.7.22" +version = "2023.11.17" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"}, - {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, + {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, + {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, ] [[package]] @@ -326,13 +326,13 @@ packaging = ">=20.9" [[package]] name = "exceptiongroup" -version = "1.1.3" +version = "1.2.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"}, - {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"}, + {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, + {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, ] [package.extras] @@ -372,13 +372,13 @@ test = ["black", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre [[package]] name = "idna" -version = "3.4" +version = "3.6" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.5" files = [ - {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, - {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, + {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, + {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, ] [[package]] @@ -508,13 +508,13 @@ files = [ [[package]] name = "platformdirs" -version = "3.11.0" +version = "4.0.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." optional = false python-versions = ">=3.7" files = [ - {file = "platformdirs-3.11.0-py3-none-any.whl", hash = "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e"}, - {file = "platformdirs-3.11.0.tar.gz", hash = "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3"}, + {file = "platformdirs-4.0.0-py3-none-any.whl", hash = "sha256:118c954d7e949b35437270383a3f2531e99dd93cf7ce4dc8340d3356d30f173b"}, + {file = "platformdirs-4.0.0.tar.gz", hash = "sha256:cb633b2bcf10c51af60beb0ab06d2f1d69064b43abf4c185ca6b28865f3f9731"}, ] [package.dependencies] @@ -935,4 +935,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = ">=3.7,<3.13" -content-hash = "016a02b0698698558aa590693fdda4a0ce558e247da4ed1eaf7b9315881575f6" +content-hash = "9c77c886972dc6da818e14a175507a87fe32c773824ef40ee3fab9c0725e9fe4" diff --git a/pyinstaller.spec b/pyinstaller.spec index 6409682a..cb3382d4 100644 --- a/pyinstaller.spec +++ b/pyinstaller.spec @@ -1,64 +1,48 @@ # -*- mode: python ; coding: utf-8 -*- # Run `poetry run pyinstaller pyinstaller.spec` to generate the binary. +# Set the env var `CYCODE_ONEDIR_MODE` to generate a single directory instead of a single file. - -block_cipher = None - -INIT_FILE_PATH = os.path.join('cycode', '__init__.py') +_INIT_FILE_PATH = os.path.join('cycode', '__init__.py') +_CODESIGN_IDENTITY = os.environ.get('APPLE_CERT_NAME') +_ONEDIR_MODE = os.environ.get('CYCODE_ONEDIR_MODE') is not None # save the prev content of __init__ file -with open(INIT_FILE_PATH, 'r', encoding='UTF-8') as file: +with open(_INIT_FILE_PATH, 'r', encoding='UTF-8') as file: prev_content = file.read() import dunamai as _dunamai + VERSION_PLACEHOLDER = '0.0.0' CLI_VERSION = _dunamai.get_version('cycode', first_choice=_dunamai.Version.from_git).serialize( metadata=False, bump=True, style=_dunamai.Style.Pep440 ) -# write version from Git Tag to freeze the value and don't depend on Git -with open(INIT_FILE_PATH, 'w', encoding='UTF-8') as file: +# write the version from Git Tag to freeze the value and don't depend on Git +with open(_INIT_FILE_PATH, 'w', encoding='UTF-8') as file: file.write(prev_content.replace(VERSION_PLACEHOLDER, CLI_VERSION)) a = Analysis( - ['cycode/cli/main.py'], - pathex=[], - binaries=[], + scripts=['cycode/cli/main.py'], datas=[('cycode/cli/config.yaml', 'cycode/cli'), ('cycode/cyclient/config.yaml', 'cycode/cyclient')], - hiddenimports=[], - hookspath=[], - hooksconfig={}, - runtime_hooks=[], excludes=['tests'], - win_no_prefer_redirects=False, - win_private_assemblies=False, - cipher=block_cipher, - noarchive=False, ) -pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe_args = [PYZ(a.pure, a.zipped_data), a.scripts, a.binaries, a.zipfiles, a.datas] +if _ONEDIR_MODE: + exe_args = [PYZ(a.pure), a.scripts] exe = EXE( - pyz, - a.scripts, - a.binaries, - a.zipfiles, - a.datas, - [], - name='cycode', - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - upx_exclude=[], - runtime_tmpdir=None, - console=True, - disable_windowed_traceback=False, - argv_emulation=False, + *exe_args, + name='cycode-cli', + exclude_binaries=bool(_ONEDIR_MODE), target_arch=None, - codesign_identity=None, - entitlements_file=None, + codesign_identity=_CODESIGN_IDENTITY, + entitlements_file='entitlements.plist', ) +if _ONEDIR_MODE: + coll = COLLECT(exe, a.binaries, a.datas, name='cycode-cli') + # rollback the prev content of the __init__ file -with open(INIT_FILE_PATH, 'w', encoding='UTF-8') as file: +with open(_INIT_FILE_PATH, 'w', encoding='UTF-8') as file: file.write(prev_content) diff --git a/pyproject.toml b/pyproject.toml index b0057fd1..272e3bb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ coverage = ">=7.2.3,<7.3.0" responses = ">=0.23.1,<0.24.0" [tool.poetry.group.executable.dependencies] -pyinstaller = ">=5.13.0,<5.14.0" +pyinstaller = ">=5.13.2,<5.14.0" dunamai = ">=1.18.0,<1.19.0" [tool.poetry.group.dev.dependencies] From dbab52b6a35a3a4071d9b43e0324cbc59ed01426 Mon Sep 17 00:00:00 2001 From: morsa4406 <104304449+morsa4406@users.noreply.github.com> Date: Mon, 1 Jan 2024 12:46:33 +0200 Subject: [PATCH 055/257] CM-30756 fixing route to scan-service and detectionsonline-service in order to skip check projects (#188) --- cycode/cyclient/scan_client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index 30036fcd..cf15798f 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -20,8 +20,8 @@ def __init__( self.scan_cycode_client = scan_cycode_client self.scan_config = scan_config - self.SCAN_CONTROLLER_PATH = 'api/v1/scan' - self.DETECTIONS_SERVICE_CONTROLLER_PATH = 'api/v1/detections' + self.SCAN_CONTROLLER_PATH = 'api/v1/cli-scan' + self.DETECTIONS_SERVICE_CONTROLLER_PATH = 'api/v1/detections/cli' self.POLICIES_SERVICE_CONTROLLER_PATH_V3 = 'api/v3/policies' self._hide_response_log = hide_response_log @@ -151,7 +151,7 @@ def get_detection_rules( return self._filter_detection_rules_by_ids(self.parse_detection_rules_response(response), detection_rules_ids) def get_scan_detections_path(self) -> str: - return f'{self.scan_config.get_detections_prefix()}/{self.DETECTIONS_SERVICE_CONTROLLER_PATH}' + return f'{self.scan_config.get_detections_prefix()}/{self.DETECTIONS_SERVICE_CONTROLLER_PATH}/detections' def get_scan_detections(self, scan_id: str) -> List[dict]: params = {'scan_id': scan_id} From 64e2f28af28410e3e3fd31276cbe1a1460ef8ac8 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 9 Jan 2024 12:12:29 +0100 Subject: [PATCH 056/257] CM-30406 - Attach onedir CLI to GitHub releases with checksums (#189) --- .github/workflows/build_executable.yml | 43 +------ process_executable_file.py | 164 +++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 41 deletions(-) create mode 100755 process_executable_file.py diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 0de165b6..dcf2a42b 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -188,47 +188,8 @@ jobs: :: verify signature signtool.exe verify /v /pa ".\dist\cycode-cli.exe" - - name: Prepare files on Windows - if: runner.os == 'Windows' - run: | - echo "ARTIFACT_NAME=cycode-win" >> $GITHUB_ENV - mv dist/cycode-cli.exe dist/cycode-win.exe - powershell -Command "(Get-FileHash -Algorithm SHA256 dist/cycode-win.exe).Hash" > sha256 - head -c 64 sha256 > dist/cycode-win.exe.sha256 - - - name: Prepare files on Intel macOS (onefile) - if: runner.os == 'macOS' && runner.arch == 'X64' && matrix.mode == 'onefile' - run: | - echo "ARTIFACT_NAME=cycode-mac" >> $GITHUB_ENV - mv dist/cycode-cli dist/cycode-mac - shasum -a 256 dist/cycode-mac > sha256 - head -c 64 sha256 > dist/cycode-mac.sha256 - - - name: Prepare files on Apple Silicon macOS (onefile) - if: runner.os == 'macOS' && runner.arch == 'ARM64' && matrix.mode == 'onefile' - run: | - echo "ARTIFACT_NAME=cycode-mac-arm" >> $GITHUB_ENV - mv dist/cycode-cli dist/cycode-mac-arm - shasum -a 256 dist/cycode-mac-arm > sha256 - head -c 64 sha256 > dist/cycode-mac-arm.sha256 - - - name: Prepare files on Intel macOS (onedir) - if: runner.os == 'macOS' && runner.arch == 'X64' && matrix.mode == 'onedir' - run: | - echo "ARTIFACT_NAME=cycode-mac-onedir" >> $GITHUB_ENV - - - name: Prepare files on Apple Silicon macOS (onedir) - if: runner.os == 'macOS' && runner.arch == 'ARM64' && matrix.mode == 'onedir' - run: | - echo "ARTIFACT_NAME=cycode-mac-arm-onedir" >> $GITHUB_ENV - - - name: Prepare files on Linux - if: runner.os == 'Linux' - run: | - echo "ARTIFACT_NAME=cycode-linux" >> $GITHUB_ENV - mv dist/cycode-cli dist/cycode-linux - sha256sum dist/cycode-linux > sha256 - head -c 64 sha256 > dist/cycode-linux.sha256 + - name: Prepare files for artifact and release (rename and calculate sha256) + run: echo "ARTIFACT_NAME=$(./process_executable_file.py dist/cycode-cli)" >> $GITHUB_ENV - name: Upload files as artifact uses: actions/upload-artifact@v3 diff --git a/process_executable_file.py b/process_executable_file.py new file mode 100755 index 00000000..eafeada3 --- /dev/null +++ b/process_executable_file.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 + +""" +Used in the GitHub Actions workflow (build_executable.yml) to process the executable file. +This script calculates hash and renames executable file depending on the OS, arch, and build mode. +It also creates a file with the hash of the executable file. +It uses SHA256 algorithm to calculate the hash. +It returns the name of the executable file which is used as artifact name. +""" + +import argparse +import hashlib +import os +import platform +from pathlib import Path +from string import Template +from typing import List, Tuple, Union + +_HASH_FILE_EXT = '.sha256' +_OS_TO_CLI_DIST_TEMPLATE = { + 'darwin': Template('cycode-mac$suffix$ext'), + 'linux': Template('cycode-linux$suffix$ext'), + 'windows': Template('cycode-win$suffix.exe$ext'), +} +_WINDOWS = 'windows' +_WINDOWS_EXECUTABLE_SUFFIX = '.exe' + +DirHashes = List[Tuple[str, str]] + + +def get_hash_of_file(file_path: Union[str, Path]) -> str: + with open(file_path, 'rb') as f: + return hashlib.sha256(f.read()).hexdigest() + + +def get_hashes_of_many_files(root: str, file_paths: List[str]) -> DirHashes: + hashes = [] + + for file_path in file_paths: + file_path = os.path.join(root, file_path) + file_hash = get_hash_of_file(file_path) + + hashes.append((file_hash, file_path)) + + return hashes + + +def get_hashes_of_every_file_in_the_directory(dir_path: Path) -> DirHashes: + hashes = [] + + for root, _, files in os.walk(dir_path): + hashes.extend(get_hashes_of_many_files(root, files,)) + + return hashes + + +def normalize_hashes_db(hashes: DirHashes, dir_path: Path) -> DirHashes: + normalized_hashes = [] + + for file_hash, file_path in hashes: + relative_file_path = file_path[file_path.find(dir_path.name):] + normalized_hashes.append((file_hash, relative_file_path)) + + # sort by file path + normalized_hashes.sort(key=lambda hash_item: hash_item[1]) + + return normalized_hashes + + +def is_arm() -> bool: + return platform.machine().lower() in ('arm', 'arm64', 'aarch64') + + +def get_os_name() -> str: + return platform.system().lower() + + +def get_cli_file_name(suffix: str = '', ext: str = '') -> str: + os_name = get_os_name() + if os_name not in _OS_TO_CLI_DIST_TEMPLATE: + raise Exception(f'Unsupported OS: {os_name}') + + template = _OS_TO_CLI_DIST_TEMPLATE[os_name] + return template.substitute(suffix=suffix, ext=ext) + + +def get_cli_file_suffix(is_onedir: bool) -> str: + suffixes = [] + + if is_arm(): + suffixes.append('-arm') + if is_onedir: + suffixes.append('-onedir') + + return ''.join(suffixes) + + +def write_hash_to_file(file_hash: str, output_path: str) -> None: + with open(output_path, 'w') as f: + f.write(file_hash) + + +def write_hashes_db_to_file(hashes: DirHashes, output_path: str) -> None: + content = '' + for file_hash, file_path in hashes: + content += f'{file_hash} {file_path}\n' + + with open(output_path, 'w') as f: + f.write(content) + + +def get_cli_filename(is_onedir: bool) -> str: + return get_cli_file_name(get_cli_file_suffix(is_onedir)) + + +def get_cli_path(output_path: Path, is_onedir: bool) -> str: + return os.path.join(output_path, get_cli_filename(is_onedir)) + + +def get_cli_hash_filename(is_onedir: bool) -> str: + return get_cli_file_name(suffix=get_cli_file_suffix(is_onedir), ext=_HASH_FILE_EXT) + + +def get_cli_hash_path(output_path: Path, is_onedir: bool) -> str: + return os.path.join(output_path, get_cli_hash_filename(is_onedir)) + + +def process_executable_file(input_path: Path, is_onedir: bool) -> str: + output_path = input_path.parent + hash_file_path = get_cli_hash_path(output_path, is_onedir) + + if is_onedir: + hashes = get_hashes_of_every_file_in_the_directory(input_path) + normalized_hashes = normalize_hashes_db(hashes, input_path) + write_hashes_db_to_file(normalized_hashes, hash_file_path) + else: + file_hash = get_hash_of_file(input_path) + write_hash_to_file(file_hash, hash_file_path) + + # for example rename cycode-cli to cycode-mac or cycode-mac-arm-onedir + os.rename(input_path, get_cli_path(output_path, is_onedir)) + + return get_cli_filename(is_onedir) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument('input', help='Path to executable or directory') + + args = parser.parse_args() + input_path = Path(args.input) + is_onedir = input_path.is_dir() + + if get_os_name() == _WINDOWS and not is_onedir and input_path.suffix != _WINDOWS_EXECUTABLE_SUFFIX: + # add .exe on windows if was missed (to simplify GHA workflow) + input_path = input_path.with_suffix(_WINDOWS_EXECUTABLE_SUFFIX) + + artifact_name = process_executable_file(input_path, is_onedir) + + print(artifact_name) # noqa: T201 + + +if __name__ == '__main__': + main() From 9fcd4ca99dbd4aa6091dd8d2c7d85d1eb0420327 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 10 Jan 2024 12:53:44 +0100 Subject: [PATCH 057/257] CM-31141 - Zip onedir CLI to attach to GitHub releases (#191) --- process_executable_file.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/process_executable_file.py b/process_executable_file.py index eafeada3..84c4081a 100755 --- a/process_executable_file.py +++ b/process_executable_file.py @@ -12,10 +12,12 @@ import hashlib import os import platform +import shutil from pathlib import Path from string import Template from typing import List, Tuple, Union +_ARCHIVE_FORMAT = 'zip' _HASH_FILE_EXT = '.sha256' _OS_TO_CLI_DIST_TEMPLATE = { 'darwin': Template('cycode-mac$suffix$ext'), @@ -125,6 +127,14 @@ def get_cli_hash_path(output_path: Path, is_onedir: bool) -> str: return os.path.join(output_path, get_cli_hash_filename(is_onedir)) +def get_cli_archive_filename(is_onedir: bool) -> str: + return get_cli_file_name(suffix=get_cli_file_suffix(is_onedir)) + + +def get_cli_archive_path(output_path: Path, is_onedir: bool) -> str: + return os.path.join(output_path, get_cli_archive_filename(is_onedir)) + + def process_executable_file(input_path: Path, is_onedir: bool) -> str: output_path = input_path.parent hash_file_path = get_cli_hash_path(output_path, is_onedir) @@ -133,6 +143,10 @@ def process_executable_file(input_path: Path, is_onedir: bool) -> str: hashes = get_hashes_of_every_file_in_the_directory(input_path) normalized_hashes = normalize_hashes_db(hashes, input_path) write_hashes_db_to_file(normalized_hashes, hash_file_path) + + archived_file_path = get_cli_archive_path(output_path, is_onedir) + shutil.make_archive(archived_file_path, _ARCHIVE_FORMAT, input_path) + shutil.rmtree(input_path) else: file_hash = get_hash_of_file(input_path) write_hash_to_file(file_hash, hash_file_path) From 9da7d6d1e52dd21258c578808a0502e9d3cb905d Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 10 Jan 2024 15:00:46 +0100 Subject: [PATCH 058/257] CM-31107 - Fix new CLI endpoints that should be applied to SCA scans only (#190) --- cycode/cli/commands/scan/code_scanner.py | 25 ++++--- cycode/cyclient/scan_client.py | 69 ++++++++++++------- cycode/cyclient/scan_config_base.py | 10 --- process_executable_file.py | 9 ++- .../cyclient/mocked_responses/scan_client.py | 8 ++- .../scan_config/test_default_scan_config.py | 6 -- .../scan_config/test_dev_scan_config.py | 6 -- 7 files changed, 70 insertions(+), 63 deletions(-) diff --git a/cycode/cli/commands/scan/code_scanner.py b/cycode/cli/commands/scan/code_scanner.py index 753f1410..d700da55 100644 --- a/cycode/cli/commands/scan/code_scanner.py +++ b/cycode/cli/commands/scan/code_scanner.py @@ -439,7 +439,7 @@ def perform_scan_async( scan_async_result = cycode_client.zipped_file_scan_async(zipped_documents, scan_type, scan_parameters) logger.debug('scan request has been triggered successfully, scan id: %s', scan_async_result.scan_id) - return poll_scan_results(cycode_client, scan_async_result.scan_id) + return poll_scan_results(cycode_client, scan_async_result.scan_id, scan_type) def perform_commit_range_scan_async( @@ -455,11 +455,14 @@ def perform_commit_range_scan_async( ) logger.debug('scan request has been triggered successfully, scan id: %s', scan_async_result.scan_id) - return poll_scan_results(cycode_client, scan_async_result.scan_id, timeout) + return poll_scan_results(cycode_client, scan_async_result.scan_id, scan_type, timeout) def poll_scan_results( - cycode_client: 'ScanClient', scan_id: str, polling_timeout: Optional[int] = None + cycode_client: 'ScanClient', + scan_id: str, + scan_type: str, + polling_timeout: Optional[int] = None, ) -> ZippedFileScanResult: if polling_timeout is None: polling_timeout = configuration_manager.get_scan_polling_timeout_in_seconds() @@ -468,14 +471,14 @@ def poll_scan_results( end_polling_time = time.time() + polling_timeout while time.time() < end_polling_time: - scan_details = cycode_client.get_scan_details(scan_id) + scan_details = cycode_client.get_scan_details(scan_type, scan_id) if scan_details.scan_update_at is not None and scan_details.scan_update_at != last_scan_update_at: last_scan_update_at = scan_details.scan_update_at print_debug_scan_details(scan_details) if scan_details.scan_status == consts.SCAN_STATUS_COMPLETED: - return _get_scan_result(cycode_client, scan_id, scan_details) + return _get_scan_result(cycode_client, scan_type, scan_id, scan_details) if scan_details.scan_status == consts.SCAN_STATUS_ERROR: raise custom_exceptions.ScanAsyncError( @@ -759,14 +762,14 @@ def _does_severity_match_severity_threshold(severity: str, severity_threshold: s def _get_scan_result( - cycode_client: 'ScanClient', scan_id: str, scan_details: 'ScanDetailsResponse' + cycode_client: 'ScanClient', scan_type: str, scan_id: str, scan_details: 'ScanDetailsResponse' ) -> ZippedFileScanResult: if not scan_details.detections_count: return init_default_scan_result(scan_id, scan_details.metadata) - wait_for_detections_creation(cycode_client, scan_id, scan_details.detections_count) + wait_for_detections_creation(cycode_client, scan_type, scan_id, scan_details.detections_count) - scan_detections = cycode_client.get_scan_detections(scan_id) + scan_detections = cycode_client.get_scan_detections(scan_type, scan_id) return ZippedFileScanResult( did_detect=True, detections_per_file=_map_detections_per_file(scan_detections), @@ -792,7 +795,9 @@ def _try_get_report_url(metadata_json: Optional[str]) -> Optional[str]: return None -def wait_for_detections_creation(cycode_client: 'ScanClient', scan_id: str, expected_detections_count: int) -> None: +def wait_for_detections_creation( + cycode_client: 'ScanClient', scan_type: str, scan_id: str, expected_detections_count: int +) -> None: logger.debug('Waiting for detections to be created') scan_persisted_detections_count = 0 @@ -800,7 +805,7 @@ def wait_for_detections_creation(cycode_client: 'ScanClient', scan_id: str, expe end_polling_time = time.time() + polling_timeout while time.time() < end_polling_time: - scan_persisted_detections_count = cycode_client.get_scan_detections_count(scan_id) + scan_persisted_detections_count = cycode_client.get_scan_detections_count(scan_type, scan_id) logger.debug( f'Excepted {expected_detections_count} detections, got {scan_persisted_detections_count} detections ' f'({expected_detections_count - scan_persisted_detections_count} more; ' diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index cf15798f..67291997 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -20,14 +20,35 @@ def __init__( self.scan_cycode_client = scan_cycode_client self.scan_config = scan_config - self.SCAN_CONTROLLER_PATH = 'api/v1/cli-scan' - self.DETECTIONS_SERVICE_CONTROLLER_PATH = 'api/v1/detections/cli' + self._SCAN_CONTROLLER_PATH = 'api/v1/scan' + self._SCAN_CONTROLLER_PATH_SCA = 'api/v1/cli-scan' + + self._DETECTIONS_SERVICE_CONTROLLER_PATH = 'api/v1/detections' + self._DETECTIONS_SERVICE_CONTROLLER_PATH_SCA = 'api/v1/detections/cli' + self.POLICIES_SERVICE_CONTROLLER_PATH_V3 = 'api/v3/policies' self._hide_response_log = hide_response_log + def get_scan_controller_path(self, scan_type: str) -> str: + if scan_type == consts.SCA_SCAN_TYPE: + return self._SCAN_CONTROLLER_PATH_SCA + + return self._SCAN_CONTROLLER_PATH + + def get_detections_service_controller_path(self, scan_type: str) -> str: + if scan_type == consts.SCA_SCAN_TYPE: + return self._DETECTIONS_SERVICE_CONTROLLER_PATH_SCA + + return self._DETECTIONS_SERVICE_CONTROLLER_PATH + + def get_scan_service_url_path(self, scan_type: str) -> str: + service_path = self.scan_config.get_service_name(scan_type) + controller_path = self.get_scan_controller_path(scan_type) + return f'{service_path}/{controller_path}' + def content_scan(self, scan_type: str, file_name: str, content: str, is_git_diff: bool = True) -> models.ScanResult: - path = f'{self.scan_config.get_service_name(scan_type)}/{self.SCAN_CONTROLLER_PATH}/content' + path = f'{self.get_scan_service_url_path(scan_type)}/content' body = {'name': file_name, 'content': content, 'is_git_diff': is_git_diff} response = self.scan_cycode_client.post( url_path=path, body=body, hide_response_content_log=self._hide_response_log @@ -35,7 +56,7 @@ def content_scan(self, scan_type: str, file_name: str, content: str, is_git_diff return self.parse_scan_response(response) def get_zipped_file_scan_url_path(self, scan_type: str) -> str: - return f'{self.scan_config.get_service_name(scan_type)}/{self.SCAN_CONTROLLER_PATH}/zipped-file' + return f'{self.get_scan_service_url_path(scan_type)}/zipped-file' def zipped_file_scan( self, scan_type: str, zip_file: InMemoryZip, scan_id: str, scan_parameters: dict, is_git_diff: bool = False @@ -54,9 +75,7 @@ def zipped_file_scan( def get_zipped_file_scan_async_url_path(self, scan_type: str) -> str: async_scan_type = self.scan_config.get_async_scan_type(scan_type) async_entity_type = self.scan_config.get_async_entity_type(scan_type) - - url_prefix = self.scan_config.get_scans_prefix() - return f'{url_prefix}/{self.SCAN_CONTROLLER_PATH}/{async_scan_type}/{async_entity_type}' + return f'{self.get_scan_service_url_path(scan_type)}/{async_scan_type}/{async_entity_type}' def zipped_file_scan_async( self, zip_file: InMemoryZip, scan_type: str, scan_parameters: dict, is_git_diff: bool = False @@ -77,9 +96,7 @@ def multiple_zipped_file_scan_async( scan_parameters: dict, is_git_diff: bool = False, ) -> models.ScanInitializationResponse: - url_path = ( - f'{self.scan_config.get_scans_prefix()}/{self.SCAN_CONTROLLER_PATH}/{scan_type}/repository/commit-range' - ) + url_path = f'{self.get_scan_service_url_path(scan_type)}/{scan_type}/repository/commit-range' files = { 'file_from_commit': ('multiple_files_scan.zip', from_commit_zip_file.read()), 'file_to_commit': ('multiple_files_scan.zip', to_commit_zip_file.read()), @@ -91,11 +108,12 @@ def multiple_zipped_file_scan_async( ) return models.ScanInitializationResponseSchema().load(response.json()) - def get_scan_details_path(self, scan_id: str) -> str: - return f'{self.scan_config.get_scans_prefix()}/{self.SCAN_CONTROLLER_PATH}/{scan_id}' + def get_scan_details_path(self, scan_type: str, scan_id: str) -> str: + return f'{self.get_scan_service_url_path(scan_type)}/{scan_id}' - def get_scan_details(self, scan_id: str) -> models.ScanDetailsResponse: - response = self.scan_cycode_client.get(url_path=self.get_scan_details_path(scan_id)) + def get_scan_details(self, scan_type: str, scan_id: str) -> models.ScanDetailsResponse: + path = self.get_scan_details_path(scan_type, scan_id) + response = self.scan_cycode_client.get(url_path=path) return models.ScanDetailsResponseSchema().load(response.json()) def get_detection_rules_path(self) -> str: @@ -150,10 +168,10 @@ def get_detection_rules( # we are filtering rules by ids in-place for smooth migration when backend will be ready return self._filter_detection_rules_by_ids(self.parse_detection_rules_response(response), detection_rules_ids) - def get_scan_detections_path(self) -> str: - return f'{self.scan_config.get_detections_prefix()}/{self.DETECTIONS_SERVICE_CONTROLLER_PATH}/detections' + def get_scan_detections_path(self, scan_type: str) -> str: + return f'{self.scan_config.get_detections_prefix()}/{self.get_detections_service_controller_path(scan_type)}' - def get_scan_detections(self, scan_id: str) -> List[dict]: + def get_scan_detections(self, scan_type: str, scan_id: str) -> List[dict]: params = {'scan_id': scan_id} page_size = 200 @@ -166,8 +184,9 @@ def get_scan_detections(self, scan_id: str) -> List[dict]: params['page_size'] = page_size params['page_number'] = page_number + path = f'{self.get_scan_detections_path(scan_type)}/detections' response = self.scan_cycode_client.get( - url_path=self.get_scan_detections_path(), + url_path=path, params=params, hide_response_content_log=self._hide_response_log, ).json() @@ -178,21 +197,19 @@ def get_scan_detections(self, scan_id: str) -> List[dict]: return detections - def get_get_scan_detections_count_path(self) -> str: - return f'{self.scan_config.get_detections_prefix()}/{self.DETECTIONS_SERVICE_CONTROLLER_PATH}/count' + def get_get_scan_detections_count_path(self, scan_type: str) -> str: + return f'{self.get_scan_detections_path(scan_type)}/count' - def get_scan_detections_count(self, scan_id: str) -> int: + def get_scan_detections_count(self, scan_type: str, scan_id: str) -> int: response = self.scan_cycode_client.get( - url_path=self.get_get_scan_detections_count_path(), params={'scan_id': scan_id} + url_path=self.get_get_scan_detections_count_path(scan_type), params={'scan_id': scan_id} ) return response.json().get('count', 0) def commit_range_zipped_file_scan( self, scan_type: str, zip_file: InMemoryZip, scan_id: str ) -> models.ZippedFileScanResult: - url_path = ( - f'{self.scan_config.get_service_name(scan_type)}/{self.SCAN_CONTROLLER_PATH}/commit-range-zipped-file' - ) + url_path = f'{self.get_scan_service_url_path(scan_type)}/commit-range-zipped-file' files = {'file': ('multiple_files_scan.zip', zip_file.read())} response = self.scan_cycode_client.post( url_path=url_path, data={'scan_id': scan_id}, files=files, hide_response_content_log=self._hide_response_log @@ -200,7 +217,7 @@ def commit_range_zipped_file_scan( return self.parse_zipped_file_scan_response(response) def get_report_scan_status_path(self, scan_type: str, scan_id: str) -> str: - return f'{self.scan_config.get_service_name(scan_type)}/{self.SCAN_CONTROLLER_PATH}/{scan_id}/status' + return f'{self.get_scan_service_url_path(scan_type)}/{scan_id}/status' def report_scan_status(self, scan_type: str, scan_id: str, scan_status: dict) -> None: self.scan_cycode_client.post(url_path=self.get_report_scan_status_path(scan_type, scan_id), body=scan_status) diff --git a/cycode/cyclient/scan_config_base.py b/cycode/cyclient/scan_config_base.py index 0b791348..347d39ef 100644 --- a/cycode/cyclient/scan_config_base.py +++ b/cycode/cyclient/scan_config_base.py @@ -20,10 +20,6 @@ def get_async_entity_type(_: str) -> str: # we are migrating to "zippedfile" entity type. will be used later return 'repository' - @abstractmethod - def get_scans_prefix(self) -> str: - ... - @abstractmethod def get_detections_prefix(self) -> str: ... @@ -39,9 +35,6 @@ def get_service_name(self, scan_type: str) -> str: # sca and sast return '5004' - def get_scans_prefix(self) -> str: - return '5004' - def get_detections_prefix(self) -> str: return '5016' @@ -56,8 +49,5 @@ def get_service_name(self, scan_type: str) -> str: # sca and sast return 'scans' - def get_scans_prefix(self) -> str: - return 'scans' - def get_detections_prefix(self) -> str: return 'detections' diff --git a/process_executable_file.py b/process_executable_file.py index 84c4081a..ad4d702a 100755 --- a/process_executable_file.py +++ b/process_executable_file.py @@ -51,7 +51,12 @@ def get_hashes_of_every_file_in_the_directory(dir_path: Path) -> DirHashes: hashes = [] for root, _, files in os.walk(dir_path): - hashes.extend(get_hashes_of_many_files(root, files,)) + hashes.extend( + get_hashes_of_many_files( + root, + files, + ) + ) return hashes @@ -60,7 +65,7 @@ def normalize_hashes_db(hashes: DirHashes, dir_path: Path) -> DirHashes: normalized_hashes = [] for file_hash, file_path in hashes: - relative_file_path = file_path[file_path.find(dir_path.name):] + relative_file_path = file_path[file_path.find(dir_path.name) :] normalized_hashes.append((file_hash, relative_file_path)) # sort by file path diff --git a/tests/cyclient/mocked_responses/scan_client.py b/tests/cyclient/mocked_responses/scan_client.py index de1613c9..55528e8f 100644 --- a/tests/cyclient/mocked_responses/scan_client.py +++ b/tests/cyclient/mocked_responses/scan_client.py @@ -119,9 +119,9 @@ def get_scan_detections_count_response(url: str) -> responses.Response: return responses.Response(method=responses.GET, url=url, json=json_response, status=200) -def get_scan_detections_url(scan_client: ScanClient) -> str: +def get_scan_detections_url(scan_client: ScanClient, scan_type: str) -> str: api_url = scan_client.scan_cycode_client.api_url - service_url = scan_client.get_scan_detections_path() + service_url = scan_client.get_scan_detections_path(scan_type) return f'{api_url}/{service_url}' @@ -168,7 +168,9 @@ def mock_scan_async_responses( responses_module.add(get_scan_details_response(get_scan_details_url(scan_id, scan_client), scan_id)) responses_module.add(get_detection_rules_response(get_detection_rules_url(scan_client))) responses_module.add(get_scan_detections_count_response(get_scan_detections_count_url(scan_client))) - responses_module.add(get_scan_detections_response(get_scan_detections_url(scan_client), scan_id, zip_content_path)) + responses_module.add( + get_scan_detections_response(get_scan_detections_url(scan_client, scan_type), scan_id, zip_content_path) + ) responses_module.add(get_report_scan_status_response(get_report_scan_status_url(scan_type, scan_id, scan_client))) diff --git a/tests/cyclient/scan_config/test_default_scan_config.py b/tests/cyclient/scan_config/test_default_scan_config.py index e0a84ad2..60b87436 100644 --- a/tests/cyclient/scan_config/test_default_scan_config.py +++ b/tests/cyclient/scan_config/test_default_scan_config.py @@ -10,12 +10,6 @@ def test_get_service_name() -> None: assert default_scan_config.get_service_name('sast') == 'scans' -def test_get_scans_prefix() -> None: - default_scan_config = DefaultScanConfig() - - assert default_scan_config.get_scans_prefix() == 'scans' - - def test_get_detections_prefix() -> None: default_scan_config = DefaultScanConfig() diff --git a/tests/cyclient/scan_config/test_dev_scan_config.py b/tests/cyclient/scan_config/test_dev_scan_config.py index 3ea3127e..4a44ff14 100644 --- a/tests/cyclient/scan_config/test_dev_scan_config.py +++ b/tests/cyclient/scan_config/test_dev_scan_config.py @@ -10,12 +10,6 @@ def test_get_service_name() -> None: assert dev_scan_config.get_service_name('sast') == '5004' -def test_get_scans_prefix() -> None: - dev_scan_config = DevScanConfig() - - assert dev_scan_config.get_scans_prefix() == '5004' - - def test_get_detections_prefix() -> None: dev_scan_config = DevScanConfig() From b956ea56235158865c453ebfc53b040ecbfa14c4 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 10 Jan 2024 15:04:08 +0100 Subject: [PATCH 059/257] CM-31143 - Migrate from black to ruff format (#192) --- .github/workflows/black.yml | 52 ----------- .github/workflows/ruff.yml | 6 +- poetry.lock | 169 +++++------------------------------- pyproject.toml | 28 ++---- 4 files changed, 30 insertions(+), 225 deletions(-) delete mode 100644 .github/workflows/black.yml diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml deleted file mode 100644 index d8003685..00000000 --- a/.github/workflows/black.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Black (code formatter) - -on: [ pull_request, push ] - -jobs: - black: - runs-on: ubuntu-latest - - steps: - - name: Run Cimon - uses: cycodelabs/cimon-action@v0 - with: - client-id: ${{ secrets.CIMON_CLIENT_ID }} - secret: ${{ secrets.CIMON_SECRET }} - prevent: true - allowed-hosts: > - files.pythonhosted.org - install.python-poetry.org - pypi.org - - - name: Checkout repository - uses: actions/checkout@v3 - - - name: Setup Python - uses: actions/setup-python@v4 - with: - python-version: 3.7 - - - name: Load cached Poetry setup - id: cached-poetry - uses: actions/cache@v3 - with: - path: ~/.local - key: poetry-ubuntu-0 # increment to reset cache - - - name: Setup Poetry - if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 - with: - version: 1.5.1 - - - name: Add Poetry to PATH - run: echo "$HOME/.local/bin" >> $GITHUB_PATH - - - name: Install dependencies - run: poetry install - - - name: Check code style of package - run: poetry run black --check cycode - - - name: Check code style of tests - run: poetry run black --check tests diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 919f4f2d..73fcf08a 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -1,11 +1,10 @@ -name: Ruff (linter) +name: Ruff (linter and code formatter) on: [ pull_request, push ] jobs: ruff: runs-on: ubuntu-latest - steps: - name: Run Cimon uses: cycodelabs/cimon-action@v0 @@ -47,3 +46,6 @@ jobs: - name: Run linter check run: poetry run ruff check . + + - name: Run code style check + run: poetry run ruff format --check . diff --git a/poetry.lock b/poetry.lock index e4e96a4d..4a33ec57 100644 --- a/poetry.lock +++ b/poetry.lock @@ -40,56 +40,6 @@ files = [ [package.dependencies] chardet = ">=3.0.2" -[[package]] -name = "black" -version = "23.3.0" -description = "The uncompromising code formatter." -optional = false -python-versions = ">=3.7" -files = [ - {file = "black-23.3.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:0945e13506be58bf7db93ee5853243eb368ace1c08a24c65ce108986eac65915"}, - {file = "black-23.3.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:67de8d0c209eb5b330cce2469503de11bca4085880d62f1628bd9972cc3366b9"}, - {file = "black-23.3.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:7c3eb7cea23904399866c55826b31c1f55bbcd3890ce22ff70466b907b6775c2"}, - {file = "black-23.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32daa9783106c28815d05b724238e30718f34155653d4d6e125dc7daec8e260c"}, - {file = "black-23.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:35d1381d7a22cc5b2be2f72c7dfdae4072a3336060635718cc7e1ede24221d6c"}, - {file = "black-23.3.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:a8a968125d0a6a404842fa1bf0b349a568634f856aa08ffaff40ae0dfa52e7c6"}, - {file = "black-23.3.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:c7ab5790333c448903c4b721b59c0d80b11fe5e9803d8703e84dcb8da56fec1b"}, - {file = "black-23.3.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:a6f6886c9869d4daae2d1715ce34a19bbc4b95006d20ed785ca00fa03cba312d"}, - {file = "black-23.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f3c333ea1dd6771b2d3777482429864f8e258899f6ff05826c3a4fcc5ce3f70"}, - {file = "black-23.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:11c410f71b876f961d1de77b9699ad19f939094c3a677323f43d7a29855fe326"}, - {file = "black-23.3.0-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:1d06691f1eb8de91cd1b322f21e3bfc9efe0c7ca1f0e1eb1db44ea367dff656b"}, - {file = "black-23.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50cb33cac881766a5cd9913e10ff75b1e8eb71babf4c7104f2e9c52da1fb7de2"}, - {file = "black-23.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:e114420bf26b90d4b9daa597351337762b63039752bdf72bf361364c1aa05925"}, - {file = "black-23.3.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:48f9d345675bb7fbc3dd85821b12487e1b9a75242028adad0333ce36ed2a6d27"}, - {file = "black-23.3.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:714290490c18fb0126baa0fca0a54ee795f7502b44177e1ce7624ba1c00f2331"}, - {file = "black-23.3.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:064101748afa12ad2291c2b91c960be28b817c0c7eaa35bec09cc63aa56493c5"}, - {file = "black-23.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:562bd3a70495facf56814293149e51aa1be9931567474993c7942ff7d3533961"}, - {file = "black-23.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:e198cf27888ad6f4ff331ca1c48ffc038848ea9f031a3b40ba36aced7e22f2c8"}, - {file = "black-23.3.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:3238f2aacf827d18d26db07524e44741233ae09a584273aa059066d644ca7b30"}, - {file = "black-23.3.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:f0bd2f4a58d6666500542b26354978218a9babcdc972722f4bf90779524515f3"}, - {file = "black-23.3.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:92c543f6854c28a3c7f39f4d9b7694f9a6eb9d3c5e2ece488c327b6e7ea9b266"}, - {file = "black-23.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a150542a204124ed00683f0db1f5cf1c2aaaa9cc3495b7a3b5976fb136090ab"}, - {file = "black-23.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:6b39abdfb402002b8a7d030ccc85cf5afff64ee90fa4c5aebc531e3ad0175ddb"}, - {file = "black-23.3.0-py3-none-any.whl", hash = "sha256:ec751418022185b0c1bb7d7736e6933d40bbb14c14a0abcf9123d1b159f98dd4"}, - {file = "black-23.3.0.tar.gz", hash = "sha256:1c7b8d606e728a41ea1ccbd7264677e494e87cf630e399262ced92d4a8dac940"}, -] - -[package.dependencies] -click = ">=8.0.0" -mypy-extensions = ">=0.4.3" -packaging = ">=22.0" -pathspec = ">=0.9.0" -platformdirs = ">=2" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""} -typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} - -[package.extras] -colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4)"] -jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] - [[package]] name = "certifi" version = "2023.11.17" @@ -462,17 +412,6 @@ build = ["blurb", "twine", "wheel"] docs = ["sphinx"] test = ["pytest (<5.4)", "pytest-cov"] -[[package]] -name = "mypy-extensions" -version = "1.0.0" -description = "Type system extensions for programs checked with the mypy type checker." -optional = false -python-versions = ">=3.5" -files = [ - {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, - {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, -] - [[package]] name = "packaging" version = "23.2" @@ -506,24 +445,6 @@ files = [ {file = "pefile-2023.2.7.tar.gz", hash = "sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc"}, ] -[[package]] -name = "platformdirs" -version = "4.0.0" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -optional = false -python-versions = ">=3.7" -files = [ - {file = "platformdirs-4.0.0-py3-none-any.whl", hash = "sha256:118c954d7e949b35437270383a3f2531e99dd93cf7ce4dc8340d3356d30f173b"}, - {file = "platformdirs-4.0.0.tar.gz", hash = "sha256:cb633b2bcf10c51af60beb0ab06d2f1d69064b43abf4c185ca6b28865f3f9731"}, -] - -[package.dependencies] -typing-extensions = {version = ">=4.7.1", markers = "python_version < \"3.8\""} - -[package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] - [[package]] name = "pluggy" version = "1.2.0" @@ -745,28 +666,28 @@ tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asy [[package]] name = "ruff" -version = "0.0.277" -description = "An extremely fast Python linter, written in Rust." +version = "0.1.11" +description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.0.277-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:3250b24333ef419b7a232080d9724ccc4d2da1dbbe4ce85c4caa2290d83200f8"}, - {file = "ruff-0.0.277-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:3e60605e07482183ba1c1b7237eca827bd6cbd3535fe8a4ede28cbe2a323cb97"}, - {file = "ruff-0.0.277-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7baa97c3d7186e5ed4d5d4f6834d759a27e56cf7d5874b98c507335f0ad5aadb"}, - {file = "ruff-0.0.277-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:74e4b206cb24f2e98a615f87dbe0bde18105217cbcc8eb785bb05a644855ba50"}, - {file = "ruff-0.0.277-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:479864a3ccd8a6a20a37a6e7577bdc2406868ee80b1e65605478ad3b8eb2ba0b"}, - {file = "ruff-0.0.277-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:468bfb0a7567443cec3d03cf408d6f562b52f30c3c29df19927f1e0e13a40cd7"}, - {file = "ruff-0.0.277-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f32ec416c24542ca2f9cc8c8b65b84560530d338aaf247a4a78e74b99cd476b4"}, - {file = "ruff-0.0.277-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:14a7b2f00f149c5a295f188a643ac25226ff8a4d08f7a62b1d4b0a1dc9f9b85c"}, - {file = "ruff-0.0.277-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9879f59f763cc5628aa01c31ad256a0f4dc61a29355c7315b83c2a5aac932b5"}, - {file = "ruff-0.0.277-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f612e0a14b3d145d90eb6ead990064e22f6f27281d847237560b4e10bf2251f3"}, - {file = "ruff-0.0.277-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:323b674c98078be9aaded5b8b51c0d9c424486566fb6ec18439b496ce79e5998"}, - {file = "ruff-0.0.277-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3a43fbe026ca1a2a8c45aa0d600a0116bec4dfa6f8bf0c3b871ecda51ef2b5dd"}, - {file = "ruff-0.0.277-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:734165ea8feb81b0d53e3bf523adc2413fdb76f1264cde99555161dd5a725522"}, - {file = "ruff-0.0.277-py3-none-win32.whl", hash = "sha256:88d0f2afb2e0c26ac1120e7061ddda2a566196ec4007bd66d558f13b374b9efc"}, - {file = "ruff-0.0.277-py3-none-win_amd64.whl", hash = "sha256:6fe81732f788894a00f6ade1fe69e996cc9e485b7c35b0f53fb00284397284b2"}, - {file = "ruff-0.0.277-py3-none-win_arm64.whl", hash = "sha256:2d4444c60f2e705c14cd802b55cd2b561d25bf4311702c463a002392d3116b22"}, - {file = "ruff-0.0.277.tar.gz", hash = "sha256:2dab13cdedbf3af6d4427c07f47143746b6b95d9e4a254ac369a0edb9280a0d2"}, + {file = "ruff-0.1.11-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a7f772696b4cdc0a3b2e527fc3c7ccc41cdcb98f5c80fdd4f2b8c50eb1458196"}, + {file = "ruff-0.1.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:934832f6ed9b34a7d5feea58972635c2039c7a3b434fe5ba2ce015064cb6e955"}, + {file = "ruff-0.1.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea0d3e950e394c4b332bcdd112aa566010a9f9c95814844a7468325290aabfd9"}, + {file = "ruff-0.1.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9bd4025b9c5b429a48280785a2b71d479798a69f5c2919e7d274c5f4b32c3607"}, + {file = "ruff-0.1.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1ad00662305dcb1e987f5ec214d31f7d6a062cae3e74c1cbccef15afd96611d"}, + {file = "ruff-0.1.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4b077ce83f47dd6bea1991af08b140e8b8339f0ba8cb9b7a484c30ebab18a23f"}, + {file = "ruff-0.1.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4a88efecec23c37b11076fe676e15c6cdb1271a38f2b415e381e87fe4517f18"}, + {file = "ruff-0.1.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b25093dad3b055667730a9b491129c42d45e11cdb7043b702e97125bcec48a1"}, + {file = "ruff-0.1.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:231d8fb11b2cc7c0366a326a66dafc6ad449d7fcdbc268497ee47e1334f66f77"}, + {file = "ruff-0.1.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:09c415716884950080921dd6237767e52e227e397e2008e2bed410117679975b"}, + {file = "ruff-0.1.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0f58948c6d212a6b8d41cd59e349751018797ce1727f961c2fa755ad6208ba45"}, + {file = "ruff-0.1.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:190a566c8f766c37074d99640cd9ca3da11d8deae2deae7c9505e68a4a30f740"}, + {file = "ruff-0.1.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6464289bd67b2344d2a5d9158d5eb81025258f169e69a46b741b396ffb0cda95"}, + {file = "ruff-0.1.11-py3-none-win32.whl", hash = "sha256:9b8f397902f92bc2e70fb6bebfa2139008dc72ae5177e66c383fa5426cb0bf2c"}, + {file = "ruff-0.1.11-py3-none-win_amd64.whl", hash = "sha256:eb85ee287b11f901037a6683b2374bb0ec82928c5cbc984f575d0437979c521a"}, + {file = "ruff-0.1.11-py3-none-win_arm64.whl", hash = "sha256:97ce4d752f964ba559c7023a86e5f8e97f026d511e48013987623915431c7ea9"}, + {file = "ruff-0.1.11.tar.gz", hash = "sha256:f9d4d88cb6eeb4dfe20f9f0519bd2eaba8119bde87c3d5065c541dbae2b5a2cb"}, ] [[package]] @@ -829,56 +750,6 @@ files = [ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] -[[package]] -name = "typed-ast" -version = "1.5.5" -description = "a fork of Python 2 and 3 ast modules with type comment support" -optional = false -python-versions = ">=3.6" -files = [ - {file = "typed_ast-1.5.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4bc1efe0ce3ffb74784e06460f01a223ac1f6ab31c6bc0376a21184bf5aabe3b"}, - {file = "typed_ast-1.5.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f7a8c46a8b333f71abd61d7ab9255440d4a588f34a21f126bbfc95f6049e686"}, - {file = "typed_ast-1.5.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:597fc66b4162f959ee6a96b978c0435bd63791e31e4f410622d19f1686d5e769"}, - {file = "typed_ast-1.5.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d41b7a686ce653e06c2609075d397ebd5b969d821b9797d029fccd71fdec8e04"}, - {file = "typed_ast-1.5.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5fe83a9a44c4ce67c796a1b466c270c1272e176603d5e06f6afbc101a572859d"}, - {file = "typed_ast-1.5.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d5c0c112a74c0e5db2c75882a0adf3133adedcdbfd8cf7c9d6ed77365ab90a1d"}, - {file = "typed_ast-1.5.5-cp310-cp310-win_amd64.whl", hash = "sha256:e1a976ed4cc2d71bb073e1b2a250892a6e968ff02aa14c1f40eba4f365ffec02"}, - {file = "typed_ast-1.5.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c631da9710271cb67b08bd3f3813b7af7f4c69c319b75475436fcab8c3d21bee"}, - {file = "typed_ast-1.5.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b445c2abfecab89a932b20bd8261488d574591173d07827c1eda32c457358b18"}, - {file = "typed_ast-1.5.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc95ffaaab2be3b25eb938779e43f513e0e538a84dd14a5d844b8f2932593d88"}, - {file = "typed_ast-1.5.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61443214d9b4c660dcf4b5307f15c12cb30bdfe9588ce6158f4a005baeb167b2"}, - {file = "typed_ast-1.5.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6eb936d107e4d474940469e8ec5b380c9b329b5f08b78282d46baeebd3692dc9"}, - {file = "typed_ast-1.5.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e48bf27022897577d8479eaed64701ecaf0467182448bd95759883300ca818c8"}, - {file = "typed_ast-1.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:83509f9324011c9a39faaef0922c6f720f9623afe3fe220b6d0b15638247206b"}, - {file = "typed_ast-1.5.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:44f214394fc1af23ca6d4e9e744804d890045d1643dd7e8229951e0ef39429b5"}, - {file = "typed_ast-1.5.5-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:118c1ce46ce58fda78503eae14b7664163aa735b620b64b5b725453696f2a35c"}, - {file = "typed_ast-1.5.5-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4919b808efa61101456e87f2d4c75b228f4e52618621c77f1ddcaae15904fa"}, - {file = "typed_ast-1.5.5-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:fc2b8c4e1bc5cd96c1a823a885e6b158f8451cf6f5530e1829390b4d27d0807f"}, - {file = "typed_ast-1.5.5-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:16f7313e0a08c7de57f2998c85e2a69a642e97cb32f87eb65fbfe88381a5e44d"}, - {file = "typed_ast-1.5.5-cp36-cp36m-win_amd64.whl", hash = "sha256:2b946ef8c04f77230489f75b4b5a4a6f24c078be4aed241cfabe9cbf4156e7e5"}, - {file = "typed_ast-1.5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2188bc33d85951ea4ddad55d2b35598b2709d122c11c75cffd529fbc9965508e"}, - {file = "typed_ast-1.5.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0635900d16ae133cab3b26c607586131269f88266954eb04ec31535c9a12ef1e"}, - {file = "typed_ast-1.5.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57bfc3cf35a0f2fdf0a88a3044aafaec1d2f24d8ae8cd87c4f58d615fb5b6311"}, - {file = "typed_ast-1.5.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:fe58ef6a764de7b4b36edfc8592641f56e69b7163bba9f9c8089838ee596bfb2"}, - {file = "typed_ast-1.5.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d09d930c2d1d621f717bb217bf1fe2584616febb5138d9b3e8cdd26506c3f6d4"}, - {file = "typed_ast-1.5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:d40c10326893ecab8a80a53039164a224984339b2c32a6baf55ecbd5b1df6431"}, - {file = "typed_ast-1.5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fd946abf3c31fb50eee07451a6aedbfff912fcd13cf357363f5b4e834cc5e71a"}, - {file = "typed_ast-1.5.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ed4a1a42df8a3dfb6b40c3d2de109e935949f2f66b19703eafade03173f8f437"}, - {file = "typed_ast-1.5.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:045f9930a1550d9352464e5149710d56a2aed23a2ffe78946478f7b5416f1ede"}, - {file = "typed_ast-1.5.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:381eed9c95484ceef5ced626355fdc0765ab51d8553fec08661dce654a935db4"}, - {file = "typed_ast-1.5.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:bfd39a41c0ef6f31684daff53befddae608f9daf6957140228a08e51f312d7e6"}, - {file = "typed_ast-1.5.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8c524eb3024edcc04e288db9541fe1f438f82d281e591c548903d5b77ad1ddd4"}, - {file = "typed_ast-1.5.5-cp38-cp38-win_amd64.whl", hash = "sha256:7f58fabdde8dcbe764cef5e1a7fcb440f2463c1bbbec1cf2a86ca7bc1f95184b"}, - {file = "typed_ast-1.5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:042eb665ff6bf020dd2243307d11ed626306b82812aba21836096d229fdc6a10"}, - {file = "typed_ast-1.5.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:622e4a006472b05cf6ef7f9f2636edc51bda670b7bbffa18d26b255269d3d814"}, - {file = "typed_ast-1.5.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1efebbbf4604ad1283e963e8915daa240cb4bf5067053cf2f0baadc4d4fb51b8"}, - {file = "typed_ast-1.5.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0aefdd66f1784c58f65b502b6cf8b121544680456d1cebbd300c2c813899274"}, - {file = "typed_ast-1.5.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:48074261a842acf825af1968cd912f6f21357316080ebaca5f19abbb11690c8a"}, - {file = "typed_ast-1.5.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:429ae404f69dc94b9361bb62291885894b7c6fb4640d561179548c849f8492ba"}, - {file = "typed_ast-1.5.5-cp39-cp39-win_amd64.whl", hash = "sha256:335f22ccb244da2b5c296e6f96b06ee9bed46526db0de38d2f0e5a6597b81155"}, - {file = "typed_ast-1.5.5.tar.gz", hash = "sha256:94282f7a354f36ef5dbce0ef3467ebf6a258e370ab33d5b40c249fa996e590dd"}, -] - [[package]] name = "types-pyyaml" version = "6.0.12.12" @@ -935,4 +806,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = ">=3.7,<3.13" -content-hash = "9c77c886972dc6da818e14a175507a87fe32c773824ef40ee3fab9c0725e9fe4" +content-hash = "9cefba1b9ec5491f9578c4b142c62535e9d551aebb7dade52fd4920d9ad6bcd6" diff --git a/pyproject.toml b/pyproject.toml index 272e3bb2..87bdd11f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,8 +52,7 @@ pyinstaller = ">=5.13.2,<5.14.0" dunamai = ">=1.18.0,<1.19.0" [tool.poetry.group.dev.dependencies] -black = ">=23.3.0,<23.4.0" -ruff = "0.0.277" +ruff = "0.1.11" [tool.pytest.ini_options] log_cli = true @@ -68,26 +67,6 @@ fix-shallow-repository=true vcs = "git" style = "pep440" -[tool.black] -line-length = 120 -skip-string-normalization=true -target-version = ['py37'] -include = '\.pyi?$' -exclude = ''' -( - /( - \.git - | \.mypy_cache - | .idea - | .pytest_cache - | venv - | htmlcov - | build - | dist - )/ -) -''' - [tool.ruff] extend-select = [ "E", # pycodestyle errors @@ -125,6 +104,8 @@ ignore = [ "ANN003", # Missing type annotation for `**kwargs` "ANN101", # Missing type annotation for `self` in method "ANN102", # Missing type annotation for `cls` in classmethod + "ANN401", # Dynamically typed expressions (typing.Any) + "ISC001", # Conflicts with ruff format ] [tool.ruff.flake8-quotes] @@ -136,6 +117,9 @@ inline-quotes = "single" "tests/*.py" = ["S101", "S105"] "cycode/*.py" = ["BLE001"] +[tool.ruff.format] +quote-style = "single" + [build-system] requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"] build-backend = "poetry_dynamic_versioning.backend" From 908f5b852481694e1756691776ea4899db0a9d24 Mon Sep 17 00:00:00 2001 From: morsa4406 <104304449+morsa4406@users.noreply.github.com> Date: Wed, 10 Jan 2024 17:04:24 +0200 Subject: [PATCH 060/257] CM-31147 - Add support reading existing restore file from directory for gradle (#193) --- .../maven/base_restore_maven_dependencies.py | 27 ++++++++++++------- .../sca/maven/restore_gradle_dependencies.py | 4 +++ .../sca/maven/restore_maven_dependencies.py | 3 +++ 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/cycode/cli/files_collector/sca/maven/base_restore_maven_dependencies.py b/cycode/cli/files_collector/sca/maven/base_restore_maven_dependencies.py index 064b9eeb..d15e1ef0 100644 --- a/cycode/cli/files_collector/sca/maven/base_restore_maven_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/base_restore_maven_dependencies.py @@ -4,7 +4,7 @@ import click from cycode.cli.models import Document -from cycode.cli.utils.path_utils import get_file_dir, join_paths +from cycode.cli.utils.path_utils import get_file_content, get_file_dir, join_paths from cycode.cli.utils.shell_executor import shell from cycode.cyclient import logger @@ -39,6 +39,23 @@ def get_manifest_file_path(self, document: Document) -> str: else document.path ) + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_file_path = self.get_manifest_file_path(document) + restore_file_path = build_dep_tree_path(document.path, self.get_lock_file_name()) + + if self.verify_restore_file_already_exist(restore_file_path): + restore_file_content = get_file_content(restore_file_path) + else: + restore_file_content = execute_command( + self.get_command(manifest_file_path), manifest_file_path, self.command_timeout + ) + + return Document(restore_file_path, restore_file_content, self.is_git_diff) + + @abstractmethod + def verify_restore_file_already_exist(self, restore_file_path: str) -> bool: + pass + @abstractmethod def is_project(self, document: Document) -> bool: pass @@ -50,11 +67,3 @@ def get_command(self, manifest_file_path: str) -> List[str]: @abstractmethod def get_lock_file_name(self) -> str: pass - - def try_restore_dependencies(self, document: Document) -> Optional[Document]: - manifest_file_path = self.get_manifest_file_path(document) - return Document( - build_dep_tree_path(document.path, self.get_lock_file_name()), - execute_command(self.get_command(manifest_file_path), manifest_file_path, self.command_timeout), - self.is_git_diff, - ) diff --git a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py index ef975ba5..21fdb7c3 100644 --- a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py @@ -1,3 +1,4 @@ +import os from typing import List import click @@ -22,3 +23,6 @@ def get_command(self, manifest_file_path: str) -> List[str]: def get_lock_file_name(self) -> str: return BUILD_GRADLE_DEP_TREE_FILE_NAME + + def verify_restore_file_already_exist(self, restore_file_path: str) -> bool: + return os.path.isfile(restore_file_path) diff --git a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py index 0e21df12..d9c117e6 100644 --- a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py @@ -29,6 +29,9 @@ def get_command(self, manifest_file_path: str) -> List[str]: def get_lock_file_name(self) -> str: return join_paths('target', MAVEN_CYCLONE_DEP_TREE_FILE_NAME) + def verify_restore_file_already_exist(self, restore_file_path: str) -> bool: + return False + def try_restore_dependencies(self, document: Document) -> Optional[Document]: restore_dependencies_document = super().try_restore_dependencies(document) manifest_file_path = self.get_manifest_file_path(document) From c6091e2a8f73d6e1083b8a6b00ca16a792bc8a4e Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Thu, 11 Jan 2024 11:00:50 +0100 Subject: [PATCH 061/257] CM-31107 - Fix detections endpoint suffix for SCA (#194) --- cycode/cyclient/scan_client.py | 14 ++++++++++---- tests/cyclient/mocked_responses/scan_client.py | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index 67291997..2b62ef62 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -171,6 +171,13 @@ def get_detection_rules( def get_scan_detections_path(self, scan_type: str) -> str: return f'{self.scan_config.get_detections_prefix()}/{self.get_detections_service_controller_path(scan_type)}' + def get_scan_detections_list_path(self, scan_type: str) -> str: + suffix = '' + if scan_type == consts.SCA_SCAN_TYPE: + suffix = '/detections' + + return f'{self.get_scan_detections_path(scan_type)}{suffix}' + def get_scan_detections(self, scan_type: str, scan_id: str) -> List[dict]: params = {'scan_id': scan_id} @@ -184,9 +191,8 @@ def get_scan_detections(self, scan_type: str, scan_id: str) -> List[dict]: params['page_size'] = page_size params['page_number'] = page_number - path = f'{self.get_scan_detections_path(scan_type)}/detections' response = self.scan_cycode_client.get( - url_path=path, + url_path=self.get_scan_detections_list_path(scan_type), params=params, hide_response_content_log=self._hide_response_log, ).json() @@ -197,12 +203,12 @@ def get_scan_detections(self, scan_type: str, scan_id: str) -> List[dict]: return detections - def get_get_scan_detections_count_path(self, scan_type: str) -> str: + def get_scan_detections_count_path(self, scan_type: str) -> str: return f'{self.get_scan_detections_path(scan_type)}/count' def get_scan_detections_count(self, scan_type: str, scan_id: str) -> int: response = self.scan_cycode_client.get( - url_path=self.get_get_scan_detections_count_path(scan_type), params={'scan_id': scan_id} + url_path=self.get_scan_detections_count_path(scan_type), params={'scan_id': scan_id} ) return response.json().get('count', 0) diff --git a/tests/cyclient/mocked_responses/scan_client.py b/tests/cyclient/mocked_responses/scan_client.py index 55528e8f..b632dc3b 100644 --- a/tests/cyclient/mocked_responses/scan_client.py +++ b/tests/cyclient/mocked_responses/scan_client.py @@ -109,7 +109,7 @@ def get_scan_details_response(url: str, scan_id: Optional[UUID] = None) -> respo def get_scan_detections_count_url(scan_client: ScanClient) -> str: api_url = scan_client.scan_cycode_client.api_url - service_url = scan_client.get_get_scan_detections_count_path() + service_url = scan_client.get_scan_detections_count_path() return f'{api_url}/{service_url}' From 1468484dcb81ea5361c518392fa201ba419db100 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Jan 2024 11:09:41 +0100 Subject: [PATCH 062/257] Bump gitpython from 3.1.40 to 3.1.41 (#195) Bumps [gitpython](https://github.com/gitpython-developers/GitPython) from 3.1.40 to 3.1.41. - [Release notes](https://github.com/gitpython-developers/GitPython/releases) - [Changelog](https://github.com/gitpython-developers/GitPython/blob/main/CHANGES) - [Commits](https://github.com/gitpython-developers/GitPython/compare/3.1.40...3.1.41) --- updated-dependencies: - dependency-name: gitpython dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 4a33ec57..79530954 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "altgraph" @@ -304,13 +304,13 @@ smmap = ">=3.0.1,<6" [[package]] name = "gitpython" -version = "3.1.40" +version = "3.1.41" description = "GitPython is a Python library used to interact with Git repositories" optional = false python-versions = ">=3.7" files = [ - {file = "GitPython-3.1.40-py3-none-any.whl", hash = "sha256:cf14627d5a8049ffbf49915732e5eddbe8134c3bdb9d476e6182b676fc573f8a"}, - {file = "GitPython-3.1.40.tar.gz", hash = "sha256:22b126e9ffb671fdd0c129796343a02bf67bf2994b35449ffc9321aa755e18a4"}, + {file = "GitPython-3.1.41-py3-none-any.whl", hash = "sha256:c36b6634d069b3f719610175020a9aed919421c87552185b085e04fbbdb10b7c"}, + {file = "GitPython-3.1.41.tar.gz", hash = "sha256:ed66e624884f76df22c8e16066d567aaa5a37d5b5fa19db2c6df6f7156db9048"}, ] [package.dependencies] @@ -318,7 +318,7 @@ gitdb = ">=4.0.1,<5" typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\""} [package.extras] -test = ["black", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit", "pytest", "pytest-cov", "pytest-instafail", "pytest-subtests", "pytest-sugar"] +test = ["black", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "sumtypes"] [[package]] name = "idna" @@ -585,6 +585,7 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -592,8 +593,15 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -610,6 +618,7 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -617,6 +626,7 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, From 7978be2c8bb91681510a95dca922a496a11b714e Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 16 Jan 2024 11:23:45 +0100 Subject: [PATCH 063/257] CM-31321 - Fix multiprocessing module in packaged CLI with Python 3.12 on macOS (#196) --- cycode/cli/main.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cycode/cli/main.py b/cycode/cli/main.py index b27c98e2..dd2d1fa7 100644 --- a/cycode/cli/main.py +++ b/cycode/cli/main.py @@ -1,4 +1,11 @@ +from multiprocessing import freeze_support + from cycode.cli.commands.main_cli import main_cli if __name__ == '__main__': + # DO NOT REMOVE OR MOVE THIS LINE + # this is required to support multiprocessing in executables files packaged with PyInstaller + # see https://pyinstaller.org/en/latest/common-issues-and-pitfalls.html#multi-processing + freeze_support() + main_cli() From 19b32271f3bfbbccff56e46ce31a0fa77fce266a Mon Sep 17 00:00:00 2001 From: morsa4406 <104304449+morsa4406@users.noreply.github.com> Date: Wed, 17 Jan 2024 17:49:10 +0200 Subject: [PATCH 064/257] CM-31412 - Fix severity sorting for unknown severity (#197) * CM-31412 [cli] - SCA- fix advisory severity is missing throw exception in sorted detections --- cycode/cli/models.py | 9 +++++++++ cycode/cli/printers/tables/sca_table_printer.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/cycode/cli/models.py b/cycode/cli/models.py index d60801df..4c6b725b 100644 --- a/cycode/cli/models.py +++ b/cycode/cli/models.py @@ -2,6 +2,7 @@ from enum import Enum from typing import Dict, List, NamedTuple, Optional, Type +from cycode.cyclient import logger from cycode.cyclient.models import Detection @@ -42,6 +43,14 @@ def try_get_value(name: str) -> any: return Severity[name].value + @staticmethod + def get_member_weight(name: str) -> any: + weight = Severity.try_get_value(name) + if weight is None: + logger.debug(f'missing severity in enum: {name}') + return -2 + return weight + class CliError(NamedTuple): code: str diff --git a/cycode/cli/printers/tables/sca_table_printer.py b/cycode/cli/printers/tables/sca_table_printer.py index e9c0e937..702a7514 100644 --- a/cycode/cli/printers/tables/sca_table_printer.py +++ b/cycode/cli/printers/tables/sca_table_printer.py @@ -74,7 +74,7 @@ def __group_by(detections: List[Detection], details_field_name: str) -> Dict[str @staticmethod def __severity_sort_key(detection: Detection) -> int: severity = detection.detection_details.get('advisory_severity') - return Severity.try_get_value(severity) + return Severity.get_member_weight(severity) def _sort_detections_by_severity(self, detections: List[Detection]) -> List[Detection]: return sorted(detections, key=self.__severity_sort_key, reverse=True) From 825b5a3106837ab99a5eb484f482e6f660d2afdb Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Mon, 22 Jan 2024 11:45:02 +0100 Subject: [PATCH 065/257] CM-31407 - Add new fields to scan parameters (#198) --- cycode/cli/commands/scan/code_scanner.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/cycode/cli/commands/scan/code_scanner.py b/cycode/cli/commands/scan/code_scanner.py index d700da55..047d0631 100644 --- a/cycode/cli/commands/scan/code_scanner.py +++ b/cycode/cli/commands/scan/code_scanner.py @@ -140,11 +140,13 @@ def _get_scan_documents_thread_func( severity_threshold = context.obj['severity_threshold'] command_scan_type = context.info_name + scan_parameters['aggregation_id'] = str(_generate_unique_id()) + def _scan_batch_thread_func(batch: List[Document]) -> Tuple[str, CliError, LocalScanResult]: local_scan_result = error = error_message = None detections_count = relevant_detections_count = zip_file_size = 0 - scan_id = str(_get_scan_id()) + scan_id = str(_generate_unique_id()) scan_completed = False try: @@ -269,6 +271,9 @@ def scan_documents( is_commit_range: bool = False, scan_parameters: Optional[dict] = None, ) -> None: + if not scan_parameters: + scan_parameters = get_default_scan_parameters(context) + progress_bar = context.obj['progress_bar'] if not documents_to_scan: @@ -309,7 +314,7 @@ def scan_commit_range_documents( local_scan_result = error_message = None scan_completed = False - scan_id = str(_get_scan_id()) + scan_id = str(_generate_unique_id()) from_commit_zipped_documents = InMemoryZip() to_commit_zipped_documents = InMemoryZip() @@ -570,12 +575,18 @@ def get_default_scan_parameters(context: click.Context) -> dict: 'report': context.obj.get('report'), 'package_vulnerabilities': context.obj.get('package-vulnerabilities'), 'license_compliance': context.obj.get('license-compliance'), + 'command_type': context.info_name, } def get_scan_parameters(context: click.Context, paths: Tuple[str]) -> dict: scan_parameters = get_default_scan_parameters(context) + if not paths: + return scan_parameters + + scan_parameters['paths'] = paths + if len(paths) != 1: # ignore remote url if multiple paths are provided return scan_parameters @@ -584,11 +595,7 @@ def get_scan_parameters(context: click.Context, paths: Tuple[str]) -> dict: if remote_url: # TODO(MarshalX): remove hardcode in context context.obj['remote_url'] = remote_url - scan_parameters.update( - { - 'remote_url': remote_url, - } - ) + scan_parameters['remote_url'] = remote_url return scan_parameters @@ -749,7 +756,7 @@ def _report_scan_status( logger.debug('Failed to report scan status, %s', {'exception_message': str(e)}) -def _get_scan_id() -> UUID: +def _generate_unique_id() -> UUID: return uuid4() From d374c4daa43d21e75a0c769ce5253d8cfdb39174 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 23 Jan 2024 11:34:41 +0100 Subject: [PATCH 066/257] CM-29444 - Add caching of access token (#199) --- cycode/cli/commands/auth/auth_command.py | 2 +- cycode/cli/commands/auth/auth_manager.py | 2 +- .../commands/configure/configure_command.py | 2 +- .../cli/user_settings/credentials_manager.py | 42 ++++++++--- cycode/cli/user_settings/jwt_creator.py | 24 ++++++ cycode/cyclient/cycode_token_based_client.py | 69 ++++++++++++----- .../configure/test_configure_command.py | 12 +-- tests/conftest.py | 19 ++++- tests/cyclient/test_token_based_client.py | 75 +++++++++++++++++-- 9 files changed, 201 insertions(+), 46 deletions(-) create mode 100644 cycode/cli/user_settings/jwt_creator.py diff --git a/cycode/cli/commands/auth/auth_command.py b/cycode/cli/commands/auth/auth_command.py index 51eb212b..87171441 100644 --- a/cycode/cli/commands/auth/auth_command.py +++ b/cycode/cli/commands/auth/auth_command.py @@ -50,7 +50,7 @@ def authorization_check(context: click.Context) -> None: return try: - if CycodeTokenBasedClient(client_id, client_secret).api_token: + if CycodeTokenBasedClient(client_id, client_secret).get_access_token(): printer.print_result(passed_auth_check_res) return except (NetworkError, HttpUnauthorizedError): diff --git a/cycode/cli/commands/auth/auth_manager.py b/cycode/cli/commands/auth/auth_manager.py index 11fbf751..829164c2 100644 --- a/cycode/cli/commands/auth/auth_manager.py +++ b/cycode/cli/commands/auth/auth_manager.py @@ -75,7 +75,7 @@ def get_api_token_polling(self, session_id: str, code_verifier: str) -> 'ApiToke raise AuthProcessError('session expired') def save_api_token(self, api_token: 'ApiToken') -> None: - self.credentials_manager.update_credentials_file(api_token.client_id, api_token.secret) + self.credentials_manager.update_credentials(api_token.client_id, api_token.secret) def _build_login_url(self, code_challenge: str, session_id: str) -> str: app_url = self.configuration_manager.get_cycode_app_url() diff --git a/cycode/cli/commands/configure/configure_command.py b/cycode/cli/commands/configure/configure_command.py index 5f9bad0e..5fe695ac 100644 --- a/cycode/cli/commands/configure/configure_command.py +++ b/cycode/cli/commands/configure/configure_command.py @@ -48,7 +48,7 @@ def configure_command() -> None: credentials_updated = False if _should_update_value(current_client_id, client_id) or _should_update_value(current_client_secret, client_secret): credentials_updated = True - _CREDENTIALS_MANAGER.update_credentials_file(client_id, client_secret) + _CREDENTIALS_MANAGER.update_credentials(client_id, client_secret) if config_updated: click.echo(_get_urls_update_result_message()) diff --git a/cycode/cli/user_settings/credentials_manager.py b/cycode/cli/user_settings/credentials_manager.py index 02653f6d..c302fc96 100644 --- a/cycode/cli/user_settings/credentials_manager.py +++ b/cycode/cli/user_settings/credentials_manager.py @@ -4,15 +4,19 @@ from cycode.cli.config import CYCODE_CLIENT_ID_ENV_VAR_NAME, CYCODE_CLIENT_SECRET_ENV_VAR_NAME from cycode.cli.user_settings.base_file_manager import BaseFileManager -from cycode.cli.utils.yaml_utils import read_file +from cycode.cli.user_settings.jwt_creator import JwtCreator class CredentialsManager(BaseFileManager): HOME_PATH: str = Path.home() CYCODE_HIDDEN_DIRECTORY: str = '.cycode' FILE_NAME: str = 'credentials.yaml' + CLIENT_ID_FIELD_NAME: str = 'cycode_client_id' CLIENT_SECRET_FIELD_NAME: str = 'cycode_client_secret' + ACCESS_TOKEN_FIELD_NAME: str = 'cycode_access_token' + ACCESS_TOKEN_EXPIRES_IN_FIELD_NAME: str = 'cycode_access_token_expires_in' + ACCESS_TOKEN_CREATOR_FIELD_NAME: str = 'cycode_access_token_creator' def get_credentials(self) -> Tuple[str, str]: client_id, client_secret = self.get_credentials_from_environment_variables() @@ -28,21 +32,37 @@ def get_credentials_from_environment_variables() -> Tuple[str, str]: return client_id, client_secret def get_credentials_from_file(self) -> Tuple[Optional[str], Optional[str]]: - credentials_filename = self.get_filename() - try: - file_content = read_file(credentials_filename) - except FileNotFoundError: - return None, None - + file_content = self.read_file() client_id = file_content.get(self.CLIENT_ID_FIELD_NAME) client_secret = file_content.get(self.CLIENT_SECRET_FIELD_NAME) return client_id, client_secret - def update_credentials_file(self, client_id: str, client_secret: str) -> None: - credentials = {self.CLIENT_ID_FIELD_NAME: client_id, self.CLIENT_SECRET_FIELD_NAME: client_secret} + def update_credentials(self, client_id: str, client_secret: str) -> None: + file_content_to_update = {self.CLIENT_ID_FIELD_NAME: client_id, self.CLIENT_SECRET_FIELD_NAME: client_secret} + self.write_content_to_file(file_content_to_update) + + def get_access_token(self) -> Tuple[Optional[str], Optional[float], Optional[JwtCreator]]: + file_content = self.read_file() + + access_token = file_content.get(self.ACCESS_TOKEN_FIELD_NAME) + expires_in = file_content.get(self.ACCESS_TOKEN_EXPIRES_IN_FIELD_NAME) + + creator = None + hashed_creator = file_content.get(self.ACCESS_TOKEN_CREATOR_FIELD_NAME) + if hashed_creator: + creator = JwtCreator(hashed_creator) + + return access_token, expires_in, creator - self.get_filename() - self.write_content_to_file(credentials) + def update_access_token( + self, access_token: Optional[str], expires_in: Optional[float], creator: Optional[JwtCreator] + ) -> None: + file_content_to_update = { + self.ACCESS_TOKEN_FIELD_NAME: access_token, + self.ACCESS_TOKEN_EXPIRES_IN_FIELD_NAME: expires_in, + self.ACCESS_TOKEN_CREATOR_FIELD_NAME: str(creator) if creator else None, + } + self.write_content_to_file(file_content_to_update) def get_filename(self) -> str: return os.path.join(self.HOME_PATH, self.CYCODE_HIDDEN_DIRECTORY, self.FILE_NAME) diff --git a/cycode/cli/user_settings/jwt_creator.py b/cycode/cli/user_settings/jwt_creator.py new file mode 100644 index 00000000..e3778f92 --- /dev/null +++ b/cycode/cli/user_settings/jwt_creator.py @@ -0,0 +1,24 @@ +from cycode.cli.utils.string_utils import hash_string_to_sha256 + +_SEPARATOR = '::' + + +def _get_hashed_creator(client_id: str, client_secret: str) -> str: + return hash_string_to_sha256(_SEPARATOR.join([client_id, client_secret])) + + +class JwtCreator: + def __init__(self, hashed_creator: str) -> None: + self._hashed_creator = hashed_creator + + def __str__(self) -> str: + return self._hashed_creator + + @classmethod + def create(cls, client_id: str, client_secret: str) -> 'JwtCreator': + return cls(_get_hashed_creator(client_id, client_secret)) + + def __eq__(self, other: 'JwtCreator') -> bool: + if not isinstance(other, JwtCreator): + return NotImplemented + return str(self) == str(other) diff --git a/cycode/cyclient/cycode_token_based_client.py b/cycode/cyclient/cycode_token_based_client.py index c73999fb..d13ce62d 100644 --- a/cycode/cyclient/cycode_token_based_client.py +++ b/cycode/cyclient/cycode_token_based_client.py @@ -2,35 +2,52 @@ from typing import Optional import arrow +from requests import Response -from .cycode_client import CycodeClient +from cycode.cli.user_settings.credentials_manager import CredentialsManager +from cycode.cli.user_settings.jwt_creator import JwtCreator +from cycode.cyclient.cycode_client import CycodeClient class CycodeTokenBasedClient(CycodeClient): - """Send requests with api token""" + """Send requests with JWT.""" def __init__(self, client_id: str, client_secret: str) -> None: super().__init__() self.client_secret = client_secret self.client_id = client_id - self._api_token = None - self._expires_in = None + self._credentials_manager = CredentialsManager() + # load cached access token + access_token, expires_in, creator = self._credentials_manager.get_access_token() + + self._access_token = self._expires_in = None + if creator == JwtCreator.create(client_id, client_secret): + # we must be sure that cached access token is created using the same client id and client secret. + # because client id and client secret could be passed via command, via env vars or via config file. + # we must not use cached access token if client id or client secret was changed. + self._access_token = access_token + self._expires_in = arrow.get(expires_in) if expires_in else None + + self._lock = Lock() - self.lock = Lock() + def get_access_token(self) -> str: + with self._lock: + self.refresh_access_token_if_needed() + return self._access_token + + def invalidate_access_token(self, in_storage: bool = False) -> None: + self._access_token = None + self._expires_in = None - @property - def api_token(self) -> str: - # TODO(MarshalX): This property performs HTTP request to refresh the token. This must be the method. - with self.lock: - self.refresh_api_token_if_needed() - return self._api_token + if in_storage: + self._credentials_manager.update_access_token(None, None, None) - def refresh_api_token_if_needed(self) -> None: - if self._api_token is None or self._expires_in is None or arrow.utcnow() >= self._expires_in: - self.refresh_api_token() + def refresh_access_token_if_needed(self) -> None: + if self._access_token is None or self._expires_in is None or arrow.utcnow() >= self._expires_in: + self.refresh_access_token() - def refresh_api_token(self) -> None: + def refresh_access_token(self) -> None: auth_response = self.post( url_path='api/v1/auth/api-token', body={'clientId': self.client_id, 'secret': self.client_secret}, @@ -39,9 +56,12 @@ def refresh_api_token(self) -> None: ) auth_response_data = auth_response.json() - self._api_token = auth_response_data['token'] + self._access_token = auth_response_data['token'] self._expires_in = arrow.utcnow().shift(seconds=auth_response_data['expires_in'] * 0.8) + jwt_creator = JwtCreator.create(self.client_id, self.client_secret) + self._credentials_manager.update_access_token(self._access_token, self._expires_in.timestamp(), jwt_creator) + def get_request_headers(self, additional_headers: Optional[dict] = None, without_auth: bool = False) -> dict: headers = super().get_request_headers(additional_headers=additional_headers) @@ -51,5 +71,20 @@ def get_request_headers(self, additional_headers: Optional[dict] = None, without return headers def _add_auth_header(self, headers: dict) -> dict: - headers['Authorization'] = f'Bearer {self.api_token}' + headers['Authorization'] = f'Bearer {self.get_access_token()}' return headers + + def _execute( + self, + *args, + **kwargs, + ) -> Response: + response = super()._execute(*args, **kwargs) + + # backend returns 200 and plain text. no way to catch it with .raise_for_status() + if response.status_code == 200 and response.content in {b'Invalid JWT Token\n\n', b'JWT Token Needed\n\n'}: + # if cached token is invalid, try to refresh it and retry the request + self.refresh_access_token() + response = super()._execute(*args, **kwargs) + + return response diff --git a/tests/cli/commands/configure/test_configure_command.py b/tests/cli/commands/configure/test_configure_command.py index 4c42971b..c5ae2b9c 100644 --- a/tests/cli/commands/configure/test_configure_command.py +++ b/tests/cli/commands/configure/test_configure_command.py @@ -35,7 +35,7 @@ def test_configure_command_no_exist_values_in_file(mocker: 'MockerFixture') -> N ) mocked_update_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials_file' + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials' ) mocked_update_api_base_url = mocker.patch( 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_api_base_url' @@ -80,7 +80,7 @@ def test_configure_command_update_current_configs_in_files(mocker: 'MockerFixtur ) mocked_update_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials_file' + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials' ) mocked_update_api_base_url = mocker.patch( 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_api_base_url' @@ -110,7 +110,7 @@ def test_set_credentials_update_only_client_id(mocker: 'MockerFixture') -> None: # side effect - multiple return values, each item in the list represents return of a call mocker.patch('click.prompt', side_effect=['', '', client_id_user_input, '']) mocked_update_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials_file' + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials' ) # Act @@ -133,7 +133,7 @@ def test_configure_command_update_only_client_secret(mocker: 'MockerFixture') -> # side effect - multiple return values, each item in the list represents return of a call mocker.patch('click.prompt', side_effect=['', '', '', client_secret_user_input]) mocked_update_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials_file' + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials' ) # Act @@ -166,7 +166,7 @@ def test_configure_command_update_only_api_url(mocker: 'MockerFixture') -> None: mocked_update_api_base_url.assert_called_once_with(api_url_user_input) -def test_configure_command_should_not_update_credentials_file(mocker: 'MockerFixture') -> None: +def test_configure_command_should_not_update_credentials(mocker: 'MockerFixture') -> None: # Arrange client_id_user_input = '' client_secret_user_input = '' @@ -179,7 +179,7 @@ def test_configure_command_should_not_update_credentials_file(mocker: 'MockerFix # side effect - multiple return values, each item in the list represents return of a call mocker.patch('click.prompt', side_effect=['', '', client_id_user_input, client_secret_user_input]) mocked_update_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials_file' + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials' ) # Act diff --git a/tests/conftest.py b/tests/conftest.py index dc1a84fe..821a0289 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,10 @@ from pathlib import Path +from typing import Optional import pytest import responses +from cycode.cli.user_settings.credentials_manager import CredentialsManager from cycode.cyclient.client_creator import create_scan_client from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient from cycode.cyclient.scan_client import ScanClient @@ -29,9 +31,22 @@ def scan_client() -> ScanClient: return create_scan_client(_CLIENT_ID, _CLIENT_SECRET, hide_response_log=False) +def create_token_based_client( + client_id: Optional[str] = None, client_secret: Optional[str] = None +) -> CycodeTokenBasedClient: + CredentialsManager.FILE_NAME = 'unit-tests-credentials.yaml' + + if client_id is None: + client_id = _CLIENT_ID + if client_secret is None: + client_secret = _CLIENT_SECRET + + return CycodeTokenBasedClient(client_id, client_secret) + + @pytest.fixture(scope='session') def token_based_client() -> CycodeTokenBasedClient: - return CycodeTokenBasedClient(_CLIENT_ID, _CLIENT_SECRET) + return create_token_based_client() @pytest.fixture(scope='session') @@ -57,4 +72,4 @@ def api_token_response(api_token_url: str) -> responses.Response: @responses.activate def api_token(token_based_client: CycodeTokenBasedClient, api_token_response: responses.Response) -> str: responses.add(api_token_response) - return token_based_client.api_token + return token_based_client.get_access_token() diff --git a/tests/cyclient/test_token_based_client.py b/tests/cyclient/test_token_based_client.py index b5d824f4..4c3dd4c5 100644 --- a/tests/cyclient/test_token_based_client.py +++ b/tests/cyclient/test_token_based_client.py @@ -2,30 +2,31 @@ import responses from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient -from tests.conftest import _EXPECTED_API_TOKEN +from tests.conftest import _EXPECTED_API_TOKEN, create_token_based_client @responses.activate -def test_api_token_new(token_based_client: CycodeTokenBasedClient, api_token_response: responses.Response) -> None: +def test_access_token_new(token_based_client: CycodeTokenBasedClient, api_token_response: responses.Response) -> None: responses.add(api_token_response) - api_token = token_based_client.api_token + api_token = token_based_client.get_access_token() assert api_token == _EXPECTED_API_TOKEN @responses.activate -def test_api_token_expired(token_based_client: CycodeTokenBasedClient, api_token_response: responses.Response) -> None: +def test_access_token_expired( + token_based_client: CycodeTokenBasedClient, api_token_response: responses.Response +) -> None: responses.add(api_token_response) - # this property performs HTTP req to refresh the token. IDE doesn't know it - token_based_client.api_token # noqa: B018 + token_based_client.get_access_token() # mark token as expired token_based_client._expires_in = arrow.utcnow().shift(hours=-1) # refresh token - api_token_refreshed = token_based_client.api_token + api_token_refreshed = token_based_client.get_access_token() assert api_token_refreshed == _EXPECTED_API_TOKEN @@ -35,3 +36,63 @@ def test_get_request_headers(token_based_client: CycodeTokenBasedClient, api_tok expected_headers = {**token_based_client.MANDATORY_HEADERS, **token_based_headers} assert token_based_client.get_request_headers() == expected_headers + + +@responses.activate +def test_access_token_cached( + token_based_client: CycodeTokenBasedClient, api_token_response: responses.Response +) -> None: + # save to cache + responses.add(api_token_response) + token_based_client.get_access_token() + + # load from cache + client2 = create_token_based_client() + assert client2._access_token == token_based_client._access_token + assert client2._expires_in == token_based_client._expires_in + + +@responses.activate +def test_access_token_cached_creator_changed( + token_based_client: CycodeTokenBasedClient, api_token_response: responses.Response +) -> None: + # save to cache + responses.add(api_token_response) + token_based_client.get_access_token() + + # load from cache with another client id and client secret + client2 = create_token_based_client('client_id2', 'client_secret2') + assert client2._access_token is None + assert client2._expires_in is None + + +@responses.activate +def test_access_token_invalidation( + token_based_client: CycodeTokenBasedClient, api_token_response: responses.Response +) -> None: + # save to cache + responses.add(api_token_response) + token_based_client.get_access_token() + + expected_access_token = token_based_client._access_token + expected_expires_in = token_based_client._expires_in + + # invalidate in runtime + token_based_client.invalidate_access_token() + assert token_based_client._access_token is None + assert token_based_client._expires_in is None + + # load from cache + client2 = create_token_based_client() + assert client2._access_token == expected_access_token + assert client2._expires_in == expected_expires_in + + # invalidate in storage + client2.invalidate_access_token(in_storage=True) + assert client2._access_token is None + assert client2._expires_in is None + + # load from cache again + client3 = create_token_based_client() + assert client3._access_token is None + assert client3._expires_in is None From 2c1bc3516c0759e60d2f8f81d9cd328e481d1834 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 23 Jan 2024 11:37:11 +0100 Subject: [PATCH 067/257] CM-31596 - Enable GitHub output in ruff workflow (#200) --- .github/workflows/ruff.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 73fcf08a..a7f86c2b 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -45,7 +45,7 @@ jobs: run: poetry install - name: Run linter check - run: poetry run ruff check . + run: poetry run ruff check --output-format=github . - name: Run code style check run: poetry run ruff format --check . From b44d406f71419061891df1382bff3bd1d86f55f6 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 23 Jan 2024 11:49:27 +0100 Subject: [PATCH 068/257] CM-31598 - Use absolute imports instead of relative ones (#201) --- cycode/cyclient/__init__.py | 2 +- cycode/cyclient/auth_client.py | 5 ++--- cycode/cyclient/cycode_client.py | 4 ++-- cycode/cyclient/cycode_client_base.py | 4 +--- cycode/cyclient/cycode_dev_based_client.py | 4 ++-- cycode/cyclient/scan_client.py | 2 +- pyproject.toml | 3 +++ 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/cycode/cyclient/__init__.py b/cycode/cyclient/__init__.py index 7018a231..9bea26e9 100644 --- a/cycode/cyclient/__init__.py +++ b/cycode/cyclient/__init__.py @@ -1,4 +1,4 @@ -from .config import logger +from cycode.cyclient.config import logger __all__ = [ 'logger', diff --git a/cycode/cyclient/auth_client.py b/cycode/cyclient/auth_client.py index 626d4ff9..91f43ad1 100644 --- a/cycode/cyclient/auth_client.py +++ b/cycode/cyclient/auth_client.py @@ -3,9 +3,8 @@ from requests import Response from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError, NetworkError - -from . import models -from .cycode_client import CycodeClient +from cycode.cyclient import models +from cycode.cyclient.cycode_client import CycodeClient class AuthClient: diff --git a/cycode/cyclient/cycode_client.py b/cycode/cyclient/cycode_client.py index dfbd2269..eded92da 100644 --- a/cycode/cyclient/cycode_client.py +++ b/cycode/cyclient/cycode_client.py @@ -1,5 +1,5 @@ -from . import config -from .cycode_client_base import CycodeClientBase +from cycode.cyclient import config +from cycode.cyclient.cycode_client_base import CycodeClientBase class CycodeClient(CycodeClientBase): diff --git a/cycode/cyclient/cycode_client_base.py b/cycode/cyclient/cycode_client_base.py index d804b8cb..a1fb68bb 100644 --- a/cycode/cyclient/cycode_client_base.py +++ b/cycode/cyclient/cycode_client_base.py @@ -6,9 +6,7 @@ from cycode import __version__ from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError, NetworkError from cycode.cli.user_settings.configuration_manager import ConfigurationManager -from cycode.cyclient import logger - -from . import config +from cycode.cyclient import config, logger def get_cli_user_agent() -> str: diff --git a/cycode/cyclient/cycode_dev_based_client.py b/cycode/cyclient/cycode_dev_based_client.py index f325bd6e..347797c3 100644 --- a/cycode/cyclient/cycode_dev_based_client.py +++ b/cycode/cyclient/cycode_dev_based_client.py @@ -1,7 +1,7 @@ from typing import Dict, Optional -from .config import dev_tenant_id -from .cycode_client_base import CycodeClientBase +from cycode.cyclient.config import dev_tenant_id +from cycode.cyclient.cycode_client_base import CycodeClientBase """ Send requests with api token diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index 2b62ef62..1318217d 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -10,7 +10,7 @@ from cycode.cyclient.cycode_client_base import CycodeClientBase if TYPE_CHECKING: - from .scan_config_base import ScanConfigBase + from cycode.cyclient.scan_config_base import ScanConfigBase class ScanClient: diff --git a/pyproject.toml b/pyproject.toml index 87bdd11f..40e28317 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -113,6 +113,9 @@ docstring-quotes = "double" multiline-quotes = "double" inline-quotes = "single" +[tool.ruff.lint.flake8-tidy-imports] +ban-relative-imports = "all" + [tool.ruff.per-file-ignores] "tests/*.py" = ["S101", "S105"] "cycode/*.py" = ["BLE001"] From ff8016c5292a2535bbda47efb201b82234394fc7 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 23 Jan 2024 14:56:30 +0100 Subject: [PATCH 069/257] CM-31613 - Add contributing guidelines (#202) --- CONTRIBUTING.md | 164 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..a3e324ca --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,164 @@ +![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/cycodehq/cycode-cli/tests.yml) +![PyPI - Version](https://img.shields.io/pypi/v/cycode) +![GitHub License](https://img.shields.io/github/license/cycodehq/cycode-cli) + +## How to contribute to Cycode CLI + +The minimum version of Python that we support is 3.7. +We recommend using this version for local development. +But it’s fine to use a higher version without using new features from these versions. +We prefer 3.8 because it comes with the support of Apple Silicon, and it is as low as possible. + +The project is under Poetry project management. +To deal with it, you should install it on your system: + +Install Poetry (feel free to use Brew, etc): + +```shell +curl -sSL https://install.python-poetry.org | python - -y +``` + +Add Poetry to PATH if required. + +Add a plugin to support dynamic versioning from Git Tags: + +```shell +poetry self add "poetry-dynamic-versioning[plugin]" +``` + +Install dependencies of the project: + +```shell +poetry install +``` + +Check that the version is valid (not 0.0.0): + +```shell +poetry version +``` + +You are ready to write code! + +To run the project use: + +```shell +poetry run cycode +``` + +or main entry point in an activated virtual environment: + +```shell +python cycode/cli/main.py +``` + +### Code linting and formatting + +We use `ruff` and `ruff format`. +It is configured well, so you don’t need to do anything. +You can see all enabled rules in the `pyproject.toml` file. +Both tests and the main codebase are checked. +Try to avoid type annotations like `Any`, etc. + +GitHub Actions will check that your code is formatted well. You can run it locally: + +```shell +# lint +poetry run ruff . +# format +poetry run ruff format . +``` + +Many rules support auto-fixing. You can run it with the `--fix` flag. + +### Branching and versioning + +We use the `main` branch as the main one. +All development should be done in feature branches. +When you are ready create a Pull Request to the `main` branch. + +Each commit in the `main` branch will be built and published to PyPI as a pre-release! +Such builds could be installed with the `--pre` flag. For example: + +```shell +pip install --pre cycode +``` + +Also, you can select a specific version of the pre-release: + +```shell +pip install cycode==1.7.2.dev6 +``` + +We are using [Semantic Versioning](https://semver.org/) and the version is generated automatically from Git Tags. So, +when you are ready to release a new version, you should create a new Git Tag. The version will be generated from it. + +Pre-release versions are generated on distance from the latest Git Tag. For example, if the latest Git Tag is `1.7.2`, +then the next pre-release version will be `1.7.2.dev1`. + +We are using GitHub Releases to create Git Tags with changelogs. +For changelogs, we are using a standard template +of [Automatically generated release notes](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes). + +### Testing + +We are using `pytest` for testing. You can run tests with: + +```shell +poetry run pytest +``` + +The library used for sending requests is [requests](https://github.com/psf/requests). +To mock requests, we are using the [responses](https://github.com/getsentry/responses) library. +All requests must be mocked. + +To see the code coverage of the project, you can run: + +```shell +poetry run coverage run -m pytest . +``` + +To generate the HTML report, you can run: + +```shell +poetry run coverage html +``` + +The report will be generated in the `htmlcov` folder. + +### Documentation + +Keep [README.md](README.md) up to date. +All CLI commands are documented automatically if you add a docstring to the command. +Clean up the changelog before release. + +### Publishing + +New versions are published automatically on the new GitHub Release. +It uses the OpenID Connect publishing mechanism to upload on PyPI. + +[Homebrew formula](https://formulae.brew.sh/formula/cycode) is updated automatically on the new PyPI release. + +The CLI is also distributed as executable files for Linux, macOS, and Windows. +It is powered by [PyInstaller](https://pyinstaller.org/) and the process is automated by GitHub Actions. +These executables are attached to GitHub Releases as assets. + +To pack the project locally, you should run: + +```shell +poetry build +``` + +It will create a `dist` folder with the package (sdist and wheel). You can install it locally: + +```shell +pip install dist/cycode-{version}-py3-none-any.whl +``` + +To create an executable file locally, you should run: + +```shell +poetry run pyinstaller pyinstaller.spec +``` + +It will create an executable file for **the current platform** in the `dist` folder. From a672f84ea051d6d1fcd6353524fdce806cae8e6c Mon Sep 17 00:00:00 2001 From: saramontif Date: Thu, 25 Jan 2024 09:00:30 +0200 Subject: [PATCH 070/257] CM-30564 - Add support for report command in secret scanning (#203) * CM-30564-Add support for report command in secret scanning * CM-30564-formatting * CM-30564-formatting * CM-30564-formatting * CM-30564-formatting * CM-30564-fix review * CM-30564-fix --- cycode/cli/commands/scan/code_scanner.py | 88 ++++++++++++++----- cycode/cyclient/models.py | 13 +++ cycode/cyclient/scan_client.py | 43 ++++++--- cycode/cyclient/scan_config_base.py | 16 +++- .../cyclient/mocked_responses/scan_client.py | 15 ++++ .../scan_config/test_default_scan_config.py | 1 + .../scan_config/test_dev_scan_config.py | 1 + tests/cyclient/test_scan_client.py | 20 ++++- tests/test_code_scanner.py | 30 +++++++ 9 files changed, 189 insertions(+), 38 deletions(-) diff --git a/cycode/cli/commands/scan/code_scanner.py b/cycode/cli/commands/scan/code_scanner.py index 047d0631..78ea8ca9 100644 --- a/cycode/cli/commands/scan/code_scanner.py +++ b/cycode/cli/commands/scan/code_scanner.py @@ -1,4 +1,3 @@ -import json import logging import os import sys @@ -99,6 +98,10 @@ def set_issue_detected_by_scan_results(context: click.Context, scan_results: Lis set_issue_detected(context, any(scan_result.issue_detected for scan_result in scan_results)) +def _should_use_scan_service(scan_type: str, scan_parameters: Optional[dict] = None) -> bool: + return scan_type == consts.SECRET_SCAN_TYPE and scan_parameters is not None and scan_parameters['report'] is True + + def _enrich_scan_result_with_data_from_detection_rules( cycode_client: 'ScanClient', scan_type: str, scan_result: ZippedFileScanResult ) -> None: @@ -148,14 +151,21 @@ def _scan_batch_thread_func(batch: List[Document]) -> Tuple[str, CliError, Local scan_id = str(_generate_unique_id()) scan_completed = False + should_use_scan_service = _should_use_scan_service(scan_type, scan_parameters) try: logger.debug('Preparing local files, %s', {'batch_size': len(batch)}) zipped_documents = zip_documents(scan_type, batch) zip_file_size = zipped_documents.size - scan_result = perform_scan( - cycode_client, zipped_documents, scan_type, scan_id, is_git_diff, is_commit_range, scan_parameters + cycode_client, + zipped_documents, + scan_type, + scan_id, + is_git_diff, + is_commit_range, + scan_parameters, + should_use_scan_service, ) _enrich_scan_result_with_data_from_detection_rules(cycode_client, scan_type, scan_result) @@ -194,6 +204,7 @@ def _scan_batch_thread_func(batch: List[Document]) -> Tuple[str, CliError, Local zip_file_size, command_scan_type, error_message, + should_use_scan_service, ) return scan_id, error, local_scan_result @@ -315,14 +326,13 @@ def scan_commit_range_documents( local_scan_result = error_message = None scan_completed = False scan_id = str(_generate_unique_id()) - from_commit_zipped_documents = InMemoryZip() to_commit_zipped_documents = InMemoryZip() try: progress_bar.set_section_length(ScanProgressBarSection.SCAN, 1) - scan_result = init_default_scan_result(scan_id) + scan_result = init_default_scan_result(cycode_client, scan_id, scan_type) if should_scan_documents(from_documents_to_scan, to_documents_to_scan): logger.debug('Preparing from-commit zip') from_commit_zipped_documents = zip_documents(scan_type, from_documents_to_scan) @@ -428,8 +438,9 @@ def perform_scan( is_git_diff: bool, is_commit_range: bool, scan_parameters: dict, + should_use_scan_service: bool = False, ) -> ZippedFileScanResult: - if scan_type in (consts.SCA_SCAN_TYPE, consts.SAST_SCAN_TYPE): + if scan_type in (consts.SCA_SCAN_TYPE, consts.SAST_SCAN_TYPE) or should_use_scan_service: return perform_scan_async(cycode_client, zipped_documents, scan_type, scan_parameters) if is_commit_range: @@ -439,12 +450,20 @@ def perform_scan( def perform_scan_async( - cycode_client: 'ScanClient', zipped_documents: 'InMemoryZip', scan_type: str, scan_parameters: dict + cycode_client: 'ScanClient', + zipped_documents: 'InMemoryZip', + scan_type: str, + scan_parameters: dict, ) -> ZippedFileScanResult: scan_async_result = cycode_client.zipped_file_scan_async(zipped_documents, scan_type, scan_parameters) logger.debug('scan request has been triggered successfully, scan id: %s', scan_async_result.scan_id) - return poll_scan_results(cycode_client, scan_async_result.scan_id, scan_type) + return poll_scan_results( + cycode_client, + scan_async_result.scan_id, + scan_type, + scan_parameters.get('report'), + ) def perform_commit_range_scan_async( @@ -460,13 +479,16 @@ def perform_commit_range_scan_async( ) logger.debug('scan request has been triggered successfully, scan id: %s', scan_async_result.scan_id) - return poll_scan_results(cycode_client, scan_async_result.scan_id, scan_type, timeout) + return poll_scan_results( + cycode_client, scan_async_result.scan_id, scan_type, scan_parameters.get('report'), timeout + ) def poll_scan_results( cycode_client: 'ScanClient', scan_id: str, scan_type: str, + should_get_report: bool = False, polling_timeout: Optional[int] = None, ) -> ZippedFileScanResult: if polling_timeout is None: @@ -483,7 +505,7 @@ def poll_scan_results( print_debug_scan_details(scan_details) if scan_details.scan_status == consts.SCAN_STATUS_COMPLETED: - return _get_scan_result(cycode_client, scan_type, scan_id, scan_details) + return _get_scan_result(cycode_client, scan_type, scan_id, scan_details, should_get_report) if scan_details.scan_status == consts.SCAN_STATUS_ERROR: raise custom_exceptions.ScanAsyncError( @@ -735,6 +757,7 @@ def _report_scan_status( zip_size: int, command_scan_type: str, error_message: Optional[str], + should_use_scan_service: bool = False, ) -> None: try: end_scan_time = time.time() @@ -751,7 +774,7 @@ def _report_scan_status( 'scan_type': scan_type, } - cycode_client.report_scan_status(scan_type, scan_id, scan_status) + cycode_client.report_scan_status(scan_type, scan_id, scan_status, should_use_scan_service) except Exception as e: logger.debug('Failed to report scan status, %s', {'exception_message': str(e)}) @@ -769,37 +792,49 @@ def _does_severity_match_severity_threshold(severity: str, severity_threshold: s def _get_scan_result( - cycode_client: 'ScanClient', scan_type: str, scan_id: str, scan_details: 'ScanDetailsResponse' + cycode_client: 'ScanClient', + scan_type: str, + scan_id: str, + scan_details: 'ScanDetailsResponse', + should_get_report: bool = False, ) -> ZippedFileScanResult: if not scan_details.detections_count: - return init_default_scan_result(scan_id, scan_details.metadata) + return init_default_scan_result(cycode_client, scan_id, scan_type, should_get_report) wait_for_detections_creation(cycode_client, scan_type, scan_id, scan_details.detections_count) scan_detections = cycode_client.get_scan_detections(scan_type, scan_id) + return ZippedFileScanResult( did_detect=True, detections_per_file=_map_detections_per_file(scan_detections), scan_id=scan_id, - report_url=_try_get_report_url(scan_details.metadata), + report_url=_try_get_report_url_if_needed(cycode_client, should_get_report, scan_id, scan_type), ) -def init_default_scan_result(scan_id: str, scan_metadata: Optional[str] = None) -> ZippedFileScanResult: +def init_default_scan_result( + cycode_client: 'ScanClient', scan_id: str, scan_type: str, should_get_report: bool = False +) -> ZippedFileScanResult: return ZippedFileScanResult( - did_detect=False, detections_per_file=[], scan_id=scan_id, report_url=_try_get_report_url(scan_metadata) + did_detect=False, + detections_per_file=[], + scan_id=scan_id, + report_url=_try_get_report_url_if_needed(cycode_client, should_get_report, scan_id, scan_type), ) -def _try_get_report_url(metadata_json: Optional[str]) -> Optional[str]: - if metadata_json is None: +def _try_get_report_url_if_needed( + cycode_client: 'ScanClient', should_get_report: bool, scan_id: str, scan_type: str +) -> Optional[str]: + if not should_get_report: return None try: - metadata_json = json.loads(metadata_json) - return metadata_json.get('report_url') - except json.JSONDecodeError: - return None + report_url_response = cycode_client.get_scan_report_url(scan_id, scan_type) + return report_url_response.report_url + except Exception as e: + logger.debug('Failed to get report url: %s', str(e)) def wait_for_detections_creation( @@ -856,9 +891,18 @@ def _get_file_name_from_detection(detection: dict) -> str: if detection['category'] == 'SAST': return detection['detection_details']['file_path'] + if detection['category'] == 'SecretDetection': + return _get_secret_file_name_from_detection(detection) + return detection['detection_details']['file_name'] +def _get_secret_file_name_from_detection(detection: dict) -> str: + file_path: str = detection['detection_details']['file_path'] + file_name: str = detection['detection_details']['file_name'] + return os.path.join(file_path, file_name) + + def _does_reach_to_max_commits_to_scan_limit(commit_ids: List[str], max_commits_count: Optional[int]) -> bool: if max_commits_count is None: return False diff --git a/cycode/cyclient/models.py b/cycode/cyclient/models.py index aef9748d..98185707 100644 --- a/cycode/cyclient/models.py +++ b/cycode/cyclient/models.py @@ -171,6 +171,19 @@ def __init__( self.err = err +@dataclass +class ScanReportUrlResponse: + report_url: str + + +class ScanReportUrlResponseSchema(Schema): + report_url = fields.String() + + @post_load + def build_dto(self, data: Dict[str, Any], **_) -> 'ScanReportUrlResponse': + return ScanReportUrlResponse(**data) + + class ScanDetailsResponseSchema(Schema): class Meta: unknown = EXCLUDE diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index 1318217d..30f45fd5 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -30,7 +30,9 @@ def __init__( self._hide_response_log = hide_response_log - def get_scan_controller_path(self, scan_type: str) -> str: + def get_scan_controller_path(self, scan_type: str, should_use_scan_service: bool = False) -> str: + if should_use_scan_service: + return self._SCAN_CONTROLLER_PATH if scan_type == consts.SCA_SCAN_TYPE: return self._SCAN_CONTROLLER_PATH_SCA @@ -42,9 +44,9 @@ def get_detections_service_controller_path(self, scan_type: str) -> str: return self._DETECTIONS_SERVICE_CONTROLLER_PATH - def get_scan_service_url_path(self, scan_type: str) -> str: - service_path = self.scan_config.get_service_name(scan_type) - controller_path = self.get_scan_controller_path(scan_type) + def get_scan_service_url_path(self, scan_type: str, should_use_scan_service: bool = False) -> str: + service_path = self.scan_config.get_service_name(scan_type, should_use_scan_service) + controller_path = self.get_scan_controller_path(scan_type, should_use_scan_service) return f'{service_path}/{controller_path}' def content_scan(self, scan_type: str, file_name: str, content: str, is_git_diff: bool = True) -> models.ScanResult: @@ -72,13 +74,22 @@ def zipped_file_scan( return self.parse_zipped_file_scan_response(response) + def get_scan_report_url(self, scan_id: str, scan_type: str) -> models.ScanReportUrlResponse: + response = self.scan_cycode_client.get(url_path=self.get_scan_report_url_path(scan_id, scan_type)) + return models.ScanReportUrlResponseSchema().build_dto(response.json()) + def get_zipped_file_scan_async_url_path(self, scan_type: str) -> str: async_scan_type = self.scan_config.get_async_scan_type(scan_type) async_entity_type = self.scan_config.get_async_entity_type(scan_type) - return f'{self.get_scan_service_url_path(scan_type)}/{async_scan_type}/{async_entity_type}' + scan_service_url_path = self.get_scan_service_url_path(scan_type, True) + return f'{scan_service_url_path}/{async_scan_type}/{async_entity_type}' def zipped_file_scan_async( - self, zip_file: InMemoryZip, scan_type: str, scan_parameters: dict, is_git_diff: bool = False + self, + zip_file: InMemoryZip, + scan_type: str, + scan_parameters: dict, + is_git_diff: bool = False, ) -> models.ScanInitializationResponse: files = {'file': ('multiple_files_scan.zip', zip_file.read())} response = self.scan_cycode_client.post( @@ -109,7 +120,10 @@ def multiple_zipped_file_scan_async( return models.ScanInitializationResponseSchema().load(response.json()) def get_scan_details_path(self, scan_type: str, scan_id: str) -> str: - return f'{self.get_scan_service_url_path(scan_type)}/{scan_id}' + return f'{self.get_scan_service_url_path(scan_type, should_use_scan_service=True)}/{scan_id}' + + def get_scan_report_url_path(self, scan_id: str, scan_type: str) -> str: + return f'{self.get_scan_service_url_path(scan_type, should_use_scan_service=True)}/reportUrl/{scan_id}' def get_scan_details(self, scan_type: str, scan_id: str) -> models.ScanDetailsResponse: path = self.get_scan_details_path(scan_type, scan_id) @@ -222,11 +236,18 @@ def commit_range_zipped_file_scan( ) return self.parse_zipped_file_scan_response(response) - def get_report_scan_status_path(self, scan_type: str, scan_id: str) -> str: - return f'{self.get_scan_service_url_path(scan_type)}/{scan_id}/status' + def get_report_scan_status_path(self, scan_type: str, scan_id: str, should_use_scan_service: bool = False) -> str: + return f'{self.get_scan_service_url_path(scan_type, should_use_scan_service)}/{scan_id}/status' - def report_scan_status(self, scan_type: str, scan_id: str, scan_status: dict) -> None: - self.scan_cycode_client.post(url_path=self.get_report_scan_status_path(scan_type, scan_id), body=scan_status) + def report_scan_status( + self, scan_type: str, scan_id: str, scan_status: dict, should_use_scan_service: bool = False + ) -> None: + self.scan_cycode_client.post( + url_path=self.get_report_scan_status_path( + scan_type, scan_id, should_use_scan_service=should_use_scan_service + ), + body=scan_status, + ) @staticmethod def parse_scan_response(response: Response) -> models.ScanResult: diff --git a/cycode/cyclient/scan_config_base.py b/cycode/cyclient/scan_config_base.py index 347d39ef..1e63673d 100644 --- a/cycode/cyclient/scan_config_base.py +++ b/cycode/cyclient/scan_config_base.py @@ -1,9 +1,11 @@ from abc import ABC, abstractmethod +from cycode.cli import consts + class ScanConfigBase(ABC): @abstractmethod - def get_service_name(self, scan_type: str) -> str: + def get_service_name(self, scan_type: str, should_use_scan_service: bool = False) -> str: ... @staticmethod @@ -16,7 +18,9 @@ def get_async_scan_type(scan_type: str) -> str: return scan_type.upper() @staticmethod - def get_async_entity_type(_: str) -> str: + def get_async_entity_type(scan_type: str) -> str: + if scan_type == consts.SECRET_SCAN_TYPE: + return 'ZippedFile' # we are migrating to "zippedfile" entity type. will be used later return 'repository' @@ -26,7 +30,9 @@ def get_detections_prefix(self) -> str: class DevScanConfig(ScanConfigBase): - def get_service_name(self, scan_type: str) -> str: + def get_service_name(self, scan_type: str, should_use_scan_service: bool = False) -> str: + if should_use_scan_service: + return '5004' if scan_type == 'secret': return '5025' if scan_type == 'iac': @@ -40,7 +46,9 @@ def get_detections_prefix(self) -> str: class DefaultScanConfig(ScanConfigBase): - def get_service_name(self, scan_type: str) -> str: + def get_service_name(self, scan_type: str, should_use_scan_service: bool = False) -> str: + if should_use_scan_service: + return 'scans' if scan_type == 'secret': return 'secret' if scan_type == 'iac': diff --git a/tests/cyclient/mocked_responses/scan_client.py b/tests/cyclient/mocked_responses/scan_client.py index b632dc3b..a3117f55 100644 --- a/tests/cyclient/mocked_responses/scan_client.py +++ b/tests/cyclient/mocked_responses/scan_client.py @@ -79,6 +79,20 @@ def get_scan_details_url(scan_id: Optional[UUID], scan_client: ScanClient) -> st return f'{api_url}/{service_url}' +def get_scan_report_url(scan_id: Optional[UUID], scan_client: ScanClient, scan_type: str) -> str: + api_url = scan_client.scan_cycode_client.api_url + service_url = scan_client.get_scan_report_url_path(str(scan_id), scan_type) + return f'{api_url}/{service_url}' + + +def get_scan_report_url_response(url: str, scan_id: Optional[UUID] = None) -> responses.Response: + if not scan_id: + scan_id = uuid4() + json_response = {'report_url': f'https://app.domain/on-demand-scans/{scan_id}'} + + return responses.Response(method=responses.GET, url=url, json=json_response, status=200) + + def get_scan_details_response(url: str, scan_id: Optional[UUID] = None) -> responses.Response: if not scan_id: scan_id = uuid4() @@ -182,3 +196,4 @@ def mock_scan_responses( ) responses_module.add(get_detection_rules_response(get_detection_rules_url(scan_client))) responses_module.add(get_report_scan_status_response(get_report_scan_status_url(scan_type, scan_id, scan_client))) + responses_module.add(get_scan_report_url_response(get_scan_report_url(scan_id, scan_client, scan_type))) diff --git a/tests/cyclient/scan_config/test_default_scan_config.py b/tests/cyclient/scan_config/test_default_scan_config.py index 60b87436..e659f71f 100644 --- a/tests/cyclient/scan_config/test_default_scan_config.py +++ b/tests/cyclient/scan_config/test_default_scan_config.py @@ -8,6 +8,7 @@ def test_get_service_name() -> None: assert default_scan_config.get_service_name('iac') == 'iac' assert default_scan_config.get_service_name('sca') == 'scans' assert default_scan_config.get_service_name('sast') == 'scans' + assert default_scan_config.get_service_name('secret', True) == 'scans' def test_get_detections_prefix() -> None: diff --git a/tests/cyclient/scan_config/test_dev_scan_config.py b/tests/cyclient/scan_config/test_dev_scan_config.py index 4a44ff14..7419b002 100644 --- a/tests/cyclient/scan_config/test_dev_scan_config.py +++ b/tests/cyclient/scan_config/test_dev_scan_config.py @@ -8,6 +8,7 @@ def test_get_service_name() -> None: assert dev_scan_config.get_service_name('iac') == '5026' assert dev_scan_config.get_service_name('sca') == '5004' assert dev_scan_config.get_service_name('sast') == '5004' + assert dev_scan_config.get_service_name('secret', should_use_scan_service=True) == '5004' def test_get_detections_prefix() -> None: diff --git a/tests/cyclient/test_scan_client.py b/tests/cyclient/test_scan_client.py index 69d657b2..0b92a0bf 100644 --- a/tests/cyclient/test_scan_client.py +++ b/tests/cyclient/test_scan_client.py @@ -15,7 +15,12 @@ from cycode.cli.models import Document from cycode.cyclient.scan_client import ScanClient from tests.conftest import ZIP_CONTENT_PATH -from tests.cyclient.mocked_responses.scan_client import get_zipped_file_scan_response, get_zipped_file_scan_url +from tests.cyclient.mocked_responses.scan_client import ( + get_scan_report_url, + get_scan_report_url_response, + get_zipped_file_scan_response, + get_zipped_file_scan_url, +) def zip_scan_resources(scan_type: str, scan_client: ScanClient) -> Tuple[str, InMemoryZip]: @@ -60,6 +65,19 @@ def test_zipped_file_scan(scan_type: str, scan_client: ScanClient, api_token_res assert zipped_file_scan_response.scan_id == str(expected_scan_id) +@pytest.mark.parametrize('scan_type', config['scans']['supported_scans']) +@responses.activate +def test_get_scan_report_url(scan_type: str, scan_client: ScanClient, api_token_response: responses.Response) -> None: + scan_id = uuid4() + url = get_scan_report_url(scan_id, scan_client, scan_type) + + responses.add(api_token_response) # mock token based client + responses.add(get_scan_report_url_response(url, scan_id)) + + scan_report_url_response = scan_client.get_scan_report_url(str(scan_id), scan_type) + assert scan_report_url_response.report_url == 'https://app.domain/on-demand-scans/{scan_id}'.format(scan_id=scan_id) + + @pytest.mark.parametrize('scan_type', config['scans']['supported_scans']) @responses.activate def test_zipped_file_scan_unauthorized_error( diff --git a/tests/test_code_scanner.py b/tests/test_code_scanner.py index 6eb494e9..f18e5c02 100644 --- a/tests/test_code_scanner.py +++ b/tests/test_code_scanner.py @@ -1,9 +1,39 @@ import os +from uuid import uuid4 +import pytest +import responses + +from cycode.cli.commands.scan.code_scanner import _try_get_report_url_if_needed +from cycode.cli.config import config from cycode.cli.files_collector.excluder import _is_relevant_file_to_scan +from cycode.cyclient.scan_client import ScanClient from tests.conftest import TEST_FILES_PATH +from tests.cyclient.mocked_responses.scan_client import get_scan_report_url, get_scan_report_url_response def test_is_relevant_file_to_scan_sca() -> None: path = os.path.join(TEST_FILES_PATH, 'package.json') assert _is_relevant_file_to_scan('sca', path) is True + + +@pytest.mark.parametrize('scan_type', config['scans']['supported_scans']) +def test_try_get_report_url_if_needed_return_none(scan_type: str, scan_client: ScanClient) -> None: + scan_id = uuid4().hex + result = _try_get_report_url_if_needed(scan_client, False, scan_id, 'secret') + assert result is None + + +@pytest.mark.parametrize('scan_type', config['scans']['supported_scans']) +@responses.activate +def test_try_get_report_url_if_needed_return_result( + scan_type: str, scan_client: ScanClient, api_token_response: responses.Response +) -> None: + scan_id = uuid4() + url = get_scan_report_url(scan_id, scan_client, scan_type) + responses.add(api_token_response) # mock token based client + responses.add(get_scan_report_url_response(url, scan_id)) + + scan_report_url_response = scan_client.get_scan_report_url(str(scan_id), scan_type) + result = _try_get_report_url_if_needed(scan_client, True, str(scan_id), scan_type) + assert result == scan_report_url_response.report_url From 3c217c4c48f0779d43f0afb4ca371fa6794fccf9 Mon Sep 17 00:00:00 2001 From: saramontif Date: Thu, 25 Jan 2024 14:21:05 +0200 Subject: [PATCH 071/257] CM-30564 - Update README for --report option (#204) CM-30564-Update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 301e8944..792b7323 100644 --- a/README.md +++ b/README.md @@ -324,7 +324,7 @@ When using this option, the scan results from this scan will appear in the knowl ### Report Option > [!NOTE] -> This option is only available to SCA scans. +> This option is only available to SCA and Secret scans. To push scan results tied to the [SCA policies](https://docs.cycode.com/docs/sca-policies) found in the Repository scan to Cycode, add the argument `--report` to the scan command. From df11e477e242843a35effa8b68653197c7e9f4ce Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Thu, 25 Jan 2024 16:48:30 +0100 Subject: [PATCH 072/257] CM-31729 - Support arrow dependency since the first stable version (#205) --- poetry.lock | 23 +++++++++-------------- pyproject.toml | 2 +- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/poetry.lock b/poetry.lock index 79530954..661b39b7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -499,15 +499,20 @@ hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"] [[package]] name = "pyinstaller-hooks-contrib" -version = "2023.10" +version = "2024.0" description = "Community maintained hooks for PyInstaller" optional = false python-versions = ">=3.7" files = [ - {file = "pyinstaller-hooks-contrib-2023.10.tar.gz", hash = "sha256:4b4a998036abb713774cb26534ca06b7e6e09e4c628196017a10deb11a48747f"}, - {file = "pyinstaller_hooks_contrib-2023.10-py2.py3-none-any.whl", hash = "sha256:6dc1786a8f452941245d5bb85893e2a33632ebdcbc4c23eea41f2ee08281b0c0"}, + {file = "pyinstaller-hooks-contrib-2024.0.tar.gz", hash = "sha256:a7118c1a5c9788595e5c43ad058a7a5b7b6d59e1eceb42362f6ec1f0b61986b0"}, + {file = "pyinstaller_hooks_contrib-2024.0-py2.py3-none-any.whl", hash = "sha256:469b5690df53223e2e8abffb2e44d6ee596e7d79d4b1eed9465123b67439875a"}, ] +[package.dependencies] +importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} +packaging = ">=22.0" +setuptools = ">=42.0.0" + [[package]] name = "pytest" version = "7.3.2" @@ -585,7 +590,6 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -593,15 +597,8 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -618,7 +615,6 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -626,7 +622,6 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -816,4 +811,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = ">=3.7,<3.13" -content-hash = "9cefba1b9ec5491f9578c4b142c62535e9d551aebb7dade52fd4920d9ad6bcd6" +content-hash = "fcbb52402ab9e081fbc204e4224e1bc0ec4466ea10f27597cb6259d60b473229" diff --git a/pyproject.toml b/pyproject.toml index 40e28317..8e76e284 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ pyyaml = ">=6.0,<7.0" marshmallow = ">=3.15.0,<3.21.0" pathspec = ">=0.11.1,<0.12.0" gitpython = ">=3.1.30,<3.2.0" -arrow = ">=0.17.0,<1.3.0" +arrow = ">=1.0.0,<1.3.0" binaryornot = ">=0.4.4,<0.5.0" texttable = ">=1.6.7,<1.8.0" requests = ">=2.24,<3.0" From 0d6cbac53d460f951426ea78b29ced9ad0b5e6f5 Mon Sep 17 00:00:00 2001 From: saramontif Date: Thu, 25 Jan 2024 18:07:13 +0200 Subject: [PATCH 073/257] CM-30564 - Print report URLs at the end of the table instead of the column (#207) * CM-30564-Change report table printer * CM-30564-Change report table printer * CM-30564-fix --- cycode/cli/printers/tables/sca_table_printer.py | 10 ---------- cycode/cli/printers/tables/table_printer.py | 7 +------ cycode/cli/printers/tables/table_printer_base.py | 10 ++++++++++ 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/cycode/cli/printers/tables/sca_table_printer.py b/cycode/cli/printers/tables/sca_table_printer.py index 702a7514..268f8614 100644 --- a/cycode/cli/printers/tables/sca_table_printer.py +++ b/cycode/cli/printers/tables/sca_table_printer.py @@ -160,16 +160,6 @@ def _enrich_table_with_values(table: Table, detection: Detection) -> None: table.set(CVE_COLUMNS, detection_details.get('vulnerability_id')) table.set(LICENSE_COLUMN, detection_details.get('license')) - @staticmethod - def _print_report_urls(local_scan_results: List['LocalScanResult']) -> None: - report_urls = [scan_result.report_url for scan_result in local_scan_results if scan_result.report_url] - if not report_urls: - return - - click.echo('Report URLs:') - for report_url in report_urls: - click.echo(f'- {report_url}') - @staticmethod def _print_summary_issues(detections_count: int, title: str) -> None: click.echo(f'⛔ Found {detections_count} issues of type: {click.style(title, bold=True)}') diff --git a/cycode/cli/printers/tables/table_printer.py b/cycode/cli/printers/tables/table_printer.py index 2aa2ca4e..6afd9e66 100644 --- a/cycode/cli/printers/tables/table_printer.py +++ b/cycode/cli/printers/tables/table_printer.py @@ -25,7 +25,6 @@ VIOLATION_LENGTH_COLUMN = column_builder.build(name='Violation Length') VIOLATION_COLUMN = column_builder.build(name='Violation') SCAN_ID_COLUMN = column_builder.build(name='Scan ID') -REPORT_URL_COLUMN = column_builder.build(name='Report URL') COLUMN_WIDTHS_CONFIG: ColumnWidthsConfig = { SECRET_SCAN_TYPE: { @@ -59,13 +58,12 @@ def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: for local_scan_result in local_scan_results: for document_detections in local_scan_result.document_detections: - report_url = local_scan_result.report_url if local_scan_result.report_url else 'N/A' for detection in document_detections.detections: - table.set(REPORT_URL_COLUMN, report_url) table.set(SCAN_ID_COLUMN, local_scan_result.scan_id) self._enrich_table_with_values(table, detection, document_detections.document) self._print_table(table) + self._print_report_urls(local_scan_results) def _get_table(self) -> Table: table = Table() @@ -85,9 +83,6 @@ def _get_table(self) -> Table: table.add(VIOLATION_LENGTH_COLUMN) table.add(VIOLATION_COLUMN) - if self.context.obj.get('report'): - table.add(REPORT_URL_COLUMN) - return table def _enrich_table_with_values(self, table: Table, detection: Detection, document: Document) -> None: diff --git a/cycode/cli/printers/tables/table_printer_base.py b/cycode/cli/printers/tables/table_printer_base.py index 10a94e55..9b6e8ac7 100644 --- a/cycode/cli/printers/tables/table_printer_base.py +++ b/cycode/cli/printers/tables/table_printer_base.py @@ -56,3 +56,13 @@ def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: def _print_table(table: 'Table') -> None: if table.get_rows(): click.echo(table.get_table().draw()) + + @staticmethod + def _print_report_urls(local_scan_results: List['LocalScanResult']) -> None: + report_urls = [scan_result.report_url for scan_result in local_scan_results if scan_result.report_url] + if not report_urls: + return + + click.echo('Report URLs:') + for report_url in report_urls: + click.echo(f'- {report_url}') From 9465e88a339eda2292f1be0ebddac3635b85fe59 Mon Sep 17 00:00:00 2001 From: anna-aleksandrowicz <135120640+anna-aleksandrowicz@users.noreply.github.com> Date: Thu, 25 Jan 2024 18:36:51 +0200 Subject: [PATCH 074/257] Updated report examples and replaced the full example with an image in the README (#206) Co-authored-by: Ilya Siamionau --- README.md | 43 ++++---------------------------------- images/sca_report_url.png | Bin 0 -> 205022 bytes 2 files changed, 4 insertions(+), 39 deletions(-) create mode 100644 images/sca_report_url.png diff --git a/README.md b/README.md index 792b7323..bf205c58 100644 --- a/README.md +++ b/README.md @@ -329,56 +329,21 @@ When using this option, the scan results from this scan will appear in the knowl To push scan results tied to the [SCA policies](https://docs.cycode.com/docs/sca-policies) found in the Repository scan to Cycode, add the argument `--report` to the scan command. `cycode scan -t sca --report repository ~/home/git/codebase` +`cycode scan -t secret --report repository ~/home/git/codebase` + or: `cycode scan --scan-type sca --report repository ~/home/git/codebase` +`cycode scan --scan-type secret --report repository ~/home/git/codebase` When using this option, the scan results from this scan will appear in the On-Demand Scans section of Cycode. To get to this page, click the link that appears after the printed results: > :warning: **NOTE**
> You must be an `owner` or an `admin` in Cycode to view this page. -```bash -Scan Results: (scan_id: e04e06e5-6dd8-474f-b409-33bbee67270b) -⛔ Found issue of type: Security vulnerability in package 'vyper' referenced in project '': Multiple evaluation of contract address in call in vyper (rule ID: d003b23a-a2eb-42f3-83c9-7a84505603e5) in file: ./requirements.txt ⛔ - -1 | PyYAML~=5.3.1 -2 | vyper==0.3.1 -3 | cleo==1.0.0a5 - -⛔ Found issue of type: Security vulnerability in package 'vyper' referenced in project '': Integer bounds error in Vyper (rule ID: d003b23a-a2eb-42f3-83c9-7a84505603e5) in file: ./requirements.txt ⛔ - -1 | PyYAML~=5.3.1 -2 | vyper==0.3.1 -3 | cleo==1.0.0a5 - -⛔ Found issue of type: Security vulnerability in package 'pyyaml' referenced in project '': Improper Input Validation in PyYAML (rule ID: d003b23a-a2eb-42f3-83c9-7a84505603e5) in file: ./requirements.txt ⛔ - -1 | PyYAML~=5.3.1 -2 | vyper==0.3.1 -3 | cleo==1.0.0a5 +![cli-report](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/sca_report_url.png) -⛔ Found issue of type: Security vulnerability in package 'cleo' referenced in project '': cleo is vulnerable to Regular Expression Denial of Service (ReDoS) (rule ID: d003b23a-a2eb-42f3-83c9-7a84505603e5) in file: ./requirements.txt ⛔ - -2 | vyper==0.3.1 -3 | cleo==1.0.0a5 -4 | - -⛔ Found issue of type: Security vulnerability in package 'vyper' referenced in project '': Incorrect Comparison in Vyper (rule ID: d003b23a-a2eb-42f3-83c9-7a84505603e5) in file: ./requirements.txt ⛔ - -1 | PyYAML~=5.3.1 -2 | vyper==0.3.1 -3 | cleo==1.0.0a5 - -⛔ Found issue of type: Security vulnerability in package 'vyper' referenced in project '': Buffer Overflow in vyper (rule ID: d003b23a-a2eb-42f3-83c9-7a84505603e5) in file: ./requirements.txt ⛔ - -1 | PyYAML~=5.3.1 -2 | vyper==0.3.1 -3 | cleo==1.0.0a5 - -Report URL: https://app.cycode.com/on-demand-scans/617ecc3d-9ff2-493e-8be8-2c1fecaf6939 -``` The report page will look something like below: diff --git a/images/sca_report_url.png b/images/sca_report_url.png new file mode 100644 index 0000000000000000000000000000000000000000..f438180ec80aa9f8f44c6588b15d7256467fcf42 GIT binary patch literal 205022 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 vO@f-O)wNl7MgPwR{{IJz{}vq(Ft%VR Date: Tue, 6 Feb 2024 14:15:11 +0100 Subject: [PATCH 075/257] CM-32048 - Fix scans runs for non-admin users (#208) --- cycode/cyclient/scan_client.py | 40 +++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index 30f45fd5..c9a299b0 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -20,33 +20,33 @@ def __init__( self.scan_cycode_client = scan_cycode_client self.scan_config = scan_config - self._SCAN_CONTROLLER_PATH = 'api/v1/scan' - self._SCAN_CONTROLLER_PATH_SCA = 'api/v1/cli-scan' + self._SCAN_SERVICE_CONTROLLER_PATH = 'api/v1/scan' + self._SCAN_SERVICE_CLI_CONTROLLER_PATH = 'api/v1/cli-scan' self._DETECTIONS_SERVICE_CONTROLLER_PATH = 'api/v1/detections' - self._DETECTIONS_SERVICE_CONTROLLER_PATH_SCA = 'api/v1/detections/cli' + self._DETECTIONS_SERVICE_CLI_CONTROLLER_PATH = 'api/v1/detections/cli' self.POLICIES_SERVICE_CONTROLLER_PATH_V3 = 'api/v3/policies' self._hide_response_log = hide_response_log - def get_scan_controller_path(self, scan_type: str, should_use_scan_service: bool = False) -> str: - if should_use_scan_service: - return self._SCAN_CONTROLLER_PATH - if scan_type == consts.SCA_SCAN_TYPE: - return self._SCAN_CONTROLLER_PATH_SCA + def get_scan_controller_path(self, scan_type: str) -> str: + if scan_type == consts.INFRA_CONFIGURATION_SCAN_TYPE: + # we don't use async flow for IaC scan yet + return self._SCAN_SERVICE_CONTROLLER_PATH - return self._SCAN_CONTROLLER_PATH + return self._SCAN_SERVICE_CLI_CONTROLLER_PATH def get_detections_service_controller_path(self, scan_type: str) -> str: - if scan_type == consts.SCA_SCAN_TYPE: - return self._DETECTIONS_SERVICE_CONTROLLER_PATH_SCA + if scan_type == consts.INFRA_CONFIGURATION_SCAN_TYPE: + # we don't use async flow for IaC scan yet + return self._DETECTIONS_SERVICE_CONTROLLER_PATH - return self._DETECTIONS_SERVICE_CONTROLLER_PATH + return self._DETECTIONS_SERVICE_CLI_CONTROLLER_PATH def get_scan_service_url_path(self, scan_type: str, should_use_scan_service: bool = False) -> str: service_path = self.scan_config.get_service_name(scan_type, should_use_scan_service) - controller_path = self.get_scan_controller_path(scan_type, should_use_scan_service) + controller_path = self.get_scan_controller_path(scan_type) return f'{service_path}/{controller_path}' def content_scan(self, scan_type: str, file_name: str, content: str, is_git_diff: bool = True) -> models.ScanResult: @@ -185,12 +185,16 @@ def get_detection_rules( def get_scan_detections_path(self, scan_type: str) -> str: return f'{self.scan_config.get_detections_prefix()}/{self.get_detections_service_controller_path(scan_type)}' - def get_scan_detections_list_path(self, scan_type: str) -> str: - suffix = '' - if scan_type == consts.SCA_SCAN_TYPE: - suffix = '/detections' + @staticmethod + def get_scan_detections_list_path_suffix(scan_type: str) -> str: + # we don't use async flow for IaC scan yet + if scan_type == consts.INFRA_CONFIGURATION_SCAN_TYPE: + return '' - return f'{self.get_scan_detections_path(scan_type)}{suffix}' + return '/detections' + + def get_scan_detections_list_path(self, scan_type: str) -> str: + return f'{self.get_scan_detections_path(scan_type)}{self.get_scan_detections_list_path_suffix(scan_type)}' def get_scan_detections(self, scan_type: str, scan_id: str) -> List[dict]: params = {'scan_id': scan_id} From 8e08dc7576a6353b5a0d6afbb986c531a0c86e56 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 6 Feb 2024 16:15:03 +0100 Subject: [PATCH 076/257] CM-32048 - Fix secret scan via detector service (#210) --- cycode/cyclient/scan_client.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index c9a299b0..00dd8d13 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -30,10 +30,14 @@ def __init__( self._hide_response_log = hide_response_log - def get_scan_controller_path(self, scan_type: str) -> str: + def get_scan_controller_path(self, scan_type: str, should_use_scan_service: bool = False) -> str: if scan_type == consts.INFRA_CONFIGURATION_SCAN_TYPE: # we don't use async flow for IaC scan yet return self._SCAN_SERVICE_CONTROLLER_PATH + if not should_use_scan_service and scan_type == consts.SECRET_SCAN_TYPE: + # if a secret scan goes to detector directly, we should not use CLI controller. + # CLI controller belongs to the scan service only + return self._SCAN_SERVICE_CONTROLLER_PATH return self._SCAN_SERVICE_CLI_CONTROLLER_PATH From 3f472b062f18b97b066c7b7ec301562bc37ba93a Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Thu, 8 Feb 2024 18:34:07 +0100 Subject: [PATCH 077/257] CM-29446 - Performance improvements for SCA (new sync flow) (#209) --- cycode/cli/commands/scan/code_scanner.py | 44 +++++++++++++++++++++--- cycode/cli/commands/scan/scan_command.py | 12 ++++++- cycode/cli/models.py | 4 +-- cycode/cyclient/cycode_client_base.py | 7 +++- cycode/cyclient/models.py | 18 ++++++++++ cycode/cyclient/scan_client.py | 34 +++++++++++++++--- 6 files changed, 106 insertions(+), 13 deletions(-) diff --git a/cycode/cli/commands/scan/code_scanner.py b/cycode/cli/commands/scan/code_scanner.py index 78ea8ca9..83da97bf 100644 --- a/cycode/cli/commands/scan/code_scanner.py +++ b/cycode/cli/commands/scan/code_scanner.py @@ -98,8 +98,21 @@ def set_issue_detected_by_scan_results(context: click.Context, scan_results: Lis set_issue_detected(context, any(scan_result.issue_detected for scan_result in scan_results)) -def _should_use_scan_service(scan_type: str, scan_parameters: Optional[dict] = None) -> bool: - return scan_type == consts.SECRET_SCAN_TYPE and scan_parameters is not None and scan_parameters['report'] is True +def _should_use_scan_service(scan_type: str, scan_parameters: dict) -> bool: + return scan_type == consts.SECRET_SCAN_TYPE and scan_parameters.get('report') is True + + +def _should_use_sync_flow(scan_type: str, sync_option: bool, scan_parameters: Optional[dict] = None) -> bool: + if not sync_option: + return False + + if scan_type not in (consts.SCA_SCAN_TYPE,): + raise ValueError(f'Sync scan is not available for {scan_type} scan type.') + + if scan_parameters.get('report') is True: + raise ValueError('You can not use sync flow with report option. Either remove "report" or "sync" option.') + + return True def _enrich_scan_result_with_data_from_detection_rules( @@ -141,6 +154,7 @@ def _get_scan_documents_thread_func( cycode_client = context.obj['client'] scan_type = context.obj['scan_type'] severity_threshold = context.obj['severity_threshold'] + sync_option = context.obj['sync'] command_scan_type = context.info_name scan_parameters['aggregation_id'] = str(_generate_unique_id()) @@ -151,7 +165,9 @@ def _scan_batch_thread_func(batch: List[Document]) -> Tuple[str, CliError, Local scan_id = str(_generate_unique_id()) scan_completed = False + should_use_scan_service = _should_use_scan_service(scan_type, scan_parameters) + should_use_sync_flow = _should_use_sync_flow(scan_type, sync_option, scan_parameters) try: logger.debug('Preparing local files, %s', {'batch_size': len(batch)}) @@ -166,6 +182,7 @@ def _scan_batch_thread_func(batch: List[Document]) -> Tuple[str, CliError, Local is_commit_range, scan_parameters, should_use_scan_service, + should_use_sync_flow, ) _enrich_scan_result_with_data_from_detection_rules(cycode_client, scan_type, scan_result) @@ -439,7 +456,11 @@ def perform_scan( is_commit_range: bool, scan_parameters: dict, should_use_scan_service: bool = False, + should_use_sync_flow: bool = False, ) -> ZippedFileScanResult: + if should_use_sync_flow: + return perform_scan_sync(cycode_client, zipped_documents, scan_type, scan_parameters) + if scan_type in (consts.SCA_SCAN_TYPE, consts.SAST_SCAN_TYPE) or should_use_scan_service: return perform_scan_async(cycode_client, zipped_documents, scan_type, scan_parameters) @@ -466,6 +487,21 @@ def perform_scan_async( ) +def perform_scan_sync( + cycode_client: 'ScanClient', + zipped_documents: 'InMemoryZip', + scan_type: str, + scan_parameters: dict, +) -> ZippedFileScanResult: + scan_results = cycode_client.zipped_file_scan_sync(zipped_documents, scan_type, scan_parameters) + logger.debug('scan request has been triggered successfully, scan id: %s', scan_results.id) + return ZippedFileScanResult( + did_detect=True, + detections_per_file=_map_detections_per_file(scan_results.detection_messages), + scan_id=scan_results.id, + ) + + def perform_commit_range_scan_async( cycode_client: 'ScanClient', from_commit_zipped_documents: 'InMemoryZip', @@ -888,10 +924,10 @@ def _map_detections_per_file(detections: List[dict]) -> List[DetectionsPerFile]: def _get_file_name_from_detection(detection: dict) -> str: - if detection['category'] == 'SAST': + if detection.get('category') == 'SAST': return detection['detection_details']['file_path'] - if detection['category'] == 'SecretDetection': + if detection.get('category') == 'SecretDetection': return _get_secret_file_name_from_detection(detection) return detection['detection_details']['file_name'] diff --git a/cycode/cli/commands/scan/scan_command.py b/cycode/cli/commands/scan/scan_command.py index a53f501b..cc97b577 100644 --- a/cycode/cli/commands/scan/scan_command.py +++ b/cycode/cli/commands/scan/scan_command.py @@ -34,7 +34,7 @@ '--scan-type', '-t', default='secret', - help='Specify the type of scan you wish to execute (the default is Secrets)', + help='Specify the type of scan you wish to execute (the default is Secrets).', type=click.Choice(config['scans']['supported_scans']), ) @click.option( @@ -100,6 +100,14 @@ type=bool, required=False, ) +@click.option( + '--sync', + is_flag=True, + default=False, + help='Run scan synchronously (the default is asynchronous).', + type=bool, + required=False, +) @click.pass_context def scan_command( context: click.Context, @@ -113,6 +121,7 @@ def scan_command( monitor: bool, report: bool, no_restore: bool, + sync: bool, ) -> int: """Scans for Secrets, IaC, SCA or SAST violations.""" if show_secret: @@ -127,6 +136,7 @@ def scan_command( context.obj['client'] = get_scan_cycode_client(client_id, secret, not context.obj['show_secret']) context.obj['scan_type'] = scan_type + context.obj['sync'] = sync context.obj['severity_threshold'] = severity_threshold context.obj['monitor'] = monitor context.obj['report'] = report diff --git a/cycode/cli/models.py b/cycode/cli/models.py index 4c6b725b..bccd4e76 100644 --- a/cycode/cli/models.py +++ b/cycode/cli/models.py @@ -2,7 +2,6 @@ from enum import Enum from typing import Dict, List, NamedTuple, Optional, Type -from cycode.cyclient import logger from cycode.cyclient.models import Detection @@ -46,8 +45,7 @@ def try_get_value(name: str) -> any: @staticmethod def get_member_weight(name: str) -> any: weight = Severity.try_get_value(name) - if weight is None: - logger.debug(f'missing severity in enum: {name}') + if weight is None: # if License Compliance return -2 return weight diff --git a/cycode/cyclient/cycode_client_base.py b/cycode/cyclient/cycode_client_base.py index a1fb68bb..6cfcd8c3 100644 --- a/cycode/cyclient/cycode_client_base.py +++ b/cycode/cyclient/cycode_client_base.py @@ -62,9 +62,14 @@ def _execute( url = self.build_full_url(self.api_url, endpoint) logger.debug(f'Executing {method.upper()} request to {url}') + timeout = self.timeout + if 'timeout' in kwargs: + timeout = kwargs['timeout'] + del kwargs['timeout'] + try: headers = self.get_request_headers(headers, without_auth=without_auth) - response = request(method=method, url=url, timeout=self.timeout, headers=headers, **kwargs) + response = request(method=method, url=url, timeout=timeout, headers=headers, **kwargs) content = 'HIDDEN' if hide_response_content_log else response.text logger.debug(f'Response {response.status_code} from {url}. Content: {content}') diff --git a/cycode/cyclient/models.py b/cycode/cyclient/models.py index 98185707..a3dff4e9 100644 --- a/cycode/cyclient/models.py +++ b/cycode/cyclient/models.py @@ -453,3 +453,21 @@ class Meta: @post_load def build_dto(self, data: Dict[str, Any], **_) -> DetectionRule: return DetectionRule(**data) + + +@dataclass +class ScanResultsSyncFlow: + id: str + detection_messages: List[Dict] + + +class ScanResultsSyncFlowSchema(Schema): + class Meta: + unknown = EXCLUDE + + id = fields.String() + detection_messages = fields.List(fields.Dict()) + + @post_load + def build_dto(self, data: Dict[str, Any], **_) -> ScanResultsSyncFlow: + return ScanResultsSyncFlow(**data) diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index 00dd8d13..c2207c9b 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -48,10 +48,20 @@ def get_detections_service_controller_path(self, scan_type: str) -> str: return self._DETECTIONS_SERVICE_CLI_CONTROLLER_PATH - def get_scan_service_url_path(self, scan_type: str, should_use_scan_service: bool = False) -> str: + @staticmethod + def get_scan_flow_type(should_use_sync_flow: bool = False) -> str: + if should_use_sync_flow: + return '/sync' + + return '' + + def get_scan_service_url_path( + self, scan_type: str, should_use_scan_service: bool = False, should_use_sync_flow: bool = False + ) -> str: service_path = self.scan_config.get_service_name(scan_type, should_use_scan_service) controller_path = self.get_scan_controller_path(scan_type) - return f'{service_path}/{controller_path}' + flow_type = self.get_scan_flow_type(should_use_sync_flow) + return f'{service_path}/{controller_path}{flow_type}' def content_scan(self, scan_type: str, file_name: str, content: str, is_git_diff: bool = True) -> models.ScanResult: path = f'{self.get_scan_service_url_path(scan_type)}/content' @@ -82,12 +92,28 @@ def get_scan_report_url(self, scan_id: str, scan_type: str) -> models.ScanReport response = self.scan_cycode_client.get(url_path=self.get_scan_report_url_path(scan_id, scan_type)) return models.ScanReportUrlResponseSchema().build_dto(response.json()) - def get_zipped_file_scan_async_url_path(self, scan_type: str) -> str: + def get_zipped_file_scan_async_url_path(self, scan_type: str, should_use_sync_flow: bool = False) -> str: async_scan_type = self.scan_config.get_async_scan_type(scan_type) async_entity_type = self.scan_config.get_async_entity_type(scan_type) - scan_service_url_path = self.get_scan_service_url_path(scan_type, True) + scan_service_url_path = self.get_scan_service_url_path( + scan_type, should_use_scan_service=True, should_use_sync_flow=should_use_sync_flow + ) return f'{scan_service_url_path}/{async_scan_type}/{async_entity_type}' + def zipped_file_scan_sync( + self, zip_file: InMemoryZip, scan_type: str, scan_parameters: dict + ) -> models.ScanResultsSyncFlow: + files = {'file': ('multiple_files_scan.zip', zip_file.read())} + del scan_parameters['report'] # BE raises validation error instead of ignoring it + response = self.scan_cycode_client.post( + url_path=self.get_zipped_file_scan_async_url_path(scan_type, should_use_sync_flow=True), + data={'scan_parameters': json.dumps(scan_parameters)}, + files=files, + hide_response_content_log=self._hide_response_log, + timeout=60, + ) + return models.ScanResultsSyncFlowSchema().load(response.json()) + def zipped_file_scan_async( self, zip_file: InMemoryZip, From 9940c92f4504dcd488f98fabf77172a461194102 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 14 Feb 2024 15:53:25 +0100 Subject: [PATCH 078/257] CM-28830 - Fix traceback printing to stderr instead of stdout (#212) --- cycode/cli/commands/auth/auth_command.py | 8 ++------ .../cli/exceptions/handle_report_sbom_errors.py | 4 +--- cycode/cli/exceptions/handle_scan_errors.py | 4 +--- cycode/cli/printers/console_printer.py | 9 +++++++-- cycode/cli/printers/printer_base.py | 16 ++++++++++++++++ 5 files changed, 27 insertions(+), 14 deletions(-) diff --git a/cycode/cli/commands/auth/auth_command.py b/cycode/cli/commands/auth/auth_command.py index 87171441..30dfab42 100644 --- a/cycode/cli/commands/auth/auth_command.py +++ b/cycode/cli/commands/auth/auth_command.py @@ -1,5 +1,3 @@ -import traceback - import click from cycode.cli.commands.auth.auth_manager import AuthManager @@ -54,16 +52,14 @@ def authorization_check(context: click.Context) -> None: printer.print_result(passed_auth_check_res) return except (NetworkError, HttpUnauthorizedError): - if context.obj['verbose']: - click.secho(f'Error: {traceback.format_exc()}', fg='red') + ConsolePrinter(context).print_exception() printer.print_result(failed_auth_check_res) return def _handle_exception(context: click.Context, e: Exception) -> None: - if context.obj['verbose']: - click.secho(f'Error: {traceback.format_exc()}', fg='red') + ConsolePrinter(context).print_exception() errors: CliErrors = { AuthProcessError: CliError( diff --git a/cycode/cli/exceptions/handle_report_sbom_errors.py b/cycode/cli/exceptions/handle_report_sbom_errors.py index b9ca9084..21f24bd2 100644 --- a/cycode/cli/exceptions/handle_report_sbom_errors.py +++ b/cycode/cli/exceptions/handle_report_sbom_errors.py @@ -1,4 +1,3 @@ -import traceback from typing import Optional import click @@ -9,8 +8,7 @@ def handle_report_exception(context: click.Context, err: Exception) -> Optional[CliError]: - if context.obj['verbose']: - click.secho(f'Error: {traceback.format_exc()}', fg='red') + ConsolePrinter(context).print_exception() errors: CliErrors = { custom_exceptions.NetworkError: CliError( diff --git a/cycode/cli/exceptions/handle_scan_errors.py b/cycode/cli/exceptions/handle_scan_errors.py index 1ee026f8..39822da2 100644 --- a/cycode/cli/exceptions/handle_scan_errors.py +++ b/cycode/cli/exceptions/handle_scan_errors.py @@ -1,4 +1,3 @@ -import traceback from typing import Optional import click @@ -14,8 +13,7 @@ def handle_scan_exception( ) -> Optional[CliError]: context.obj['did_fail'] = True - if context.obj['verbose']: - click.secho(f'Error: {traceback.format_exc()}', fg='red') + ConsolePrinter(context).print_exception() errors: CliErrors = { custom_exceptions.NetworkError: CliError( diff --git a/cycode/cli/printers/console_printer.py b/cycode/cli/printers/console_printer.py index d9ae56df..667cdd6a 100644 --- a/cycode/cli/printers/console_printer.py +++ b/cycode/cli/printers/console_printer.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, ClassVar, Dict, List, Optional +from typing import TYPE_CHECKING, ClassVar, Dict, List, Optional, Type import click @@ -15,7 +15,7 @@ class ConsolePrinter: - _AVAILABLE_PRINTERS: ClassVar[Dict[str, 'PrinterBase']] = { + _AVAILABLE_PRINTERS: ClassVar[Dict[str, Type['PrinterBase']]] = { 'text': TextPrinter, 'json': JsonPrinter, 'table': TablePrinter, @@ -53,3 +53,8 @@ def print_result(self, result: CliResult) -> None: def print_error(self, error: CliError) -> None: self._printer_class(self.context).print_error(error) + + def print_exception(self, e: Optional[BaseException] = None, force_print: bool = False) -> None: + """Print traceback message in stderr if verbose mode is set.""" + if force_print or self.context.obj.get('verbose', False): + self._printer_class(self.context).print_exception(e) diff --git a/cycode/cli/printers/printer_base.py b/cycode/cli/printers/printer_base.py index e1fbfa51..97a0aff5 100644 --- a/cycode/cli/printers/printer_base.py +++ b/cycode/cli/printers/printer_base.py @@ -1,3 +1,4 @@ +import traceback from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Dict, List, Optional @@ -30,3 +31,18 @@ def print_result(self, result: CliResult) -> None: @abstractmethod def print_error(self, error: CliError) -> None: pass + + def print_exception(self, e: Optional[BaseException] = None) -> None: + """We are printing it in stderr so, we don't care about supporting JSON and TABLE outputs. + + Note: + Called only when the verbose flag is set. + """ + if e is None: + # gets the most recent exception caught by an except clause + message = f'Error: {traceback.format_exc()}' + else: + traceback_message = ''.join(traceback.format_exception(e)) + message = f'Error: {traceback_message}' + + click.secho(message, err=True, fg=self.RED_COLOR_NAME) From ae85c8fb1cc29cda65f9ba2086a5044cd6b9eacd Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 28 Feb 2024 15:11:34 +0100 Subject: [PATCH 079/257] CM-32756, CM-32757 - Update README (#214) --- README.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index bf205c58..7077e695 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ This guide will guide you through both installation and usage. 2. [IaC Result Example](#iac-result-example) 3. [SCA Result Example](#sca-result-example) 4. [SAST Result Example](#sast-result-example) + 4. [Company’s Custom Remediation Guidelines](#companys-custom-remediation-guidelines) 3. [Ignoring Scan Results](#ignoring-scan-results) 1. [Ignoring a Secret Value](#ignoring-a-secret-value) 2. [Ignoring a Secret SHA Value](#ignoring-a-secret-sha-value) @@ -68,9 +69,10 @@ To install the Cycode CLI application on your local machine, perform the followi 1. Open your command line or terminal application. -2. Execute the following command: +2. Execute one of the following commands: - `pip3 install cycode` + - `pip3 install cycode` - to install from PyPI + - `brew install cycode` - to install from Homebrew 3. Navigate to the top directory of the local repository you wish to scan. @@ -324,14 +326,13 @@ When using this option, the scan results from this scan will appear in the knowl ### Report Option > [!NOTE] -> This option is only available to SCA and Secret scans. +> This option is not available to IaC scans. To push scan results tied to the [SCA policies](https://docs.cycode.com/docs/sca-policies) found in the Repository scan to Cycode, add the argument `--report` to the scan command. `cycode scan -t sca --report repository ~/home/git/codebase` `cycode scan -t secret --report repository ~/home/git/codebase` - or: `cycode scan --scan-type sca --report repository ~/home/git/codebase` @@ -559,6 +560,10 @@ Secret SHA: a44081db3296c84b82d12a35c446a3cba19411dddfa0380134c75f7b3973bff0 4 | print(res.content) ``` +### Company’s Custom Remediation Guidelines + +If your company has set custom remediation guidelines in the relevant policy via the Cycode portal, you'll see a field for “Company Guidelines” that contains the remediation guidelines you added. Note that if you haven't added any company guideline, this field will not appear in the CLI tool. + ## Ignoring Scan Results Ignore rules can be added to ignore specific secret values, specific SHA512 values, specific paths, and specific Cycode secret and IaC rule IDs. This will cause the scan to not alert these values. The ignore rules are written and saved locally in the `./.cycode/config.yaml` file. From 383f3897c6e69aef68baa4f1c637d34a484a06ce Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 28 Feb 2024 15:28:48 +0100 Subject: [PATCH 080/257] CM-32925 - Add Report URLs and Scan IDs to JSON output (#215) --- cycode/cli/printers/json_printer.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/cycode/cli/printers/json_printer.py b/cycode/cli/printers/json_printer.py index 89b903ad..44ec9c85 100644 --- a/cycode/cli/printers/json_printer.py +++ b/cycode/cli/printers/json_printer.py @@ -25,8 +25,16 @@ def print_error(self, error: CliError) -> None: def print_scan_results( self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None ) -> None: + scan_ids = [] + report_urls = [] detections = [] + for local_scan_result in local_scan_results: + scan_ids.append(local_scan_result.scan_id) + + if local_scan_result.report_url: + report_urls.append(local_scan_result.report_url) + for document_detections in local_scan_result.document_detections: detections.extend(document_detections.detections) @@ -37,12 +45,16 @@ def print_scan_results( # FIXME(MarshalX): we don't care about scan IDs in JSON output due to clumsy JSON root structure inlined_errors = [err._asdict() for err in errors.values()] - click.echo(self._get_json_scan_result(detections_dict, inlined_errors)) + click.echo(self._get_json_scan_result(scan_ids, detections_dict, report_urls, inlined_errors)) - def _get_json_scan_result(self, detections: dict, errors: List[dict]) -> str: + def _get_json_scan_result( + self, scan_ids: List[str], detections: dict, report_urls: List[str], errors: List[dict] + ) -> str: result = { - 'scan_id': 'DEPRECATED', # FIXME(MarshalX): we need change JSON struct to support multiple scan results + 'scan_id': 'DEPRECATED', # backward compatibility + 'scan_ids': scan_ids, 'detections': detections, + 'report_urls': report_urls, 'errors': errors, } From 8134a448d743606f6c2bff959646171365427598 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Thu, 28 Mar 2024 13:59:16 +0100 Subject: [PATCH 081/257] CM-33703 - Add severity for IaC (#216) --- cycode/cli/commands/scan/code_scanner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cycode/cli/commands/scan/code_scanner.py b/cycode/cli/commands/scan/code_scanner.py index 83da97bf..14d72d7e 100644 --- a/cycode/cli/commands/scan/code_scanner.py +++ b/cycode/cli/commands/scan/code_scanner.py @@ -119,7 +119,7 @@ def _enrich_scan_result_with_data_from_detection_rules( cycode_client: 'ScanClient', scan_type: str, scan_result: ZippedFileScanResult ) -> None: # TODO(MarshalX): remove scan_type arg after migration to new backend filter - if scan_type != consts.SECRET_SCAN_TYPE: + if scan_type not in {consts.SECRET_SCAN_TYPE, consts.INFRA_CONFIGURATION_SCAN_TYPE}: # not yet return From ee6d00666004c3c0bde42b25d2a54aef83aec517 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Thu, 4 Apr 2024 12:29:23 +0200 Subject: [PATCH 082/257] CM-34657 - Update CODEOWNERS (#217) --- CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index 874040fd..32a2011c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1 +1 @@ -* @MarshalX @MichalBor @MaorDavidzon @artem-fedorov +* @MarshalX @MichalBor @MaorDavidzon @artem-fedorov @elsapet @gotbadger @cfabianski From e1e159c8042072dd1142507a933e719bcc9034c9 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Fri, 5 Apr 2024 11:55:54 +0200 Subject: [PATCH 083/257] CM-34678 - Add Remediation Guidelines and Description to JSON output (#218) --- cycode/cli/commands/scan/code_scanner.py | 2 ++ cycode/cyclient/models.py | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/cycode/cli/commands/scan/code_scanner.py b/cycode/cli/commands/scan/code_scanner.py index 14d72d7e..dacec86e 100644 --- a/cycode/cli/commands/scan/code_scanner.py +++ b/cycode/cli/commands/scan/code_scanner.py @@ -146,6 +146,8 @@ def _enrich_scan_result_with_data_from_detection_rules( # detection_details never was typed properly. so not a problem for now detection.detection_details['custom_remediation_guidelines'] = detection_rule.custom_remediation_guidelines + detection.detection_details['remediation_guidelines'] = detection_rule.remediation_guidelines + detection.detection_details['description'] = detection_rule.description def _get_scan_documents_thread_func( diff --git a/cycode/cyclient/models.py b/cycode/cyclient/models.py index a3dff4e9..c6c0c2a3 100644 --- a/cycode/cyclient/models.py +++ b/cycode/cyclient/models.py @@ -440,6 +440,8 @@ class DetectionRule: classification_data: List[ClassificationData] detection_rule_id: str custom_remediation_guidelines: Optional[str] = None + remediation_guidelines: Optional[str] = None + description: Optional[str] = None class DetectionRuleSchema(Schema): @@ -449,6 +451,8 @@ class Meta: classification_data = fields.Nested(ClassificationDataSchema, many=True) detection_rule_id = fields.String() custom_remediation_guidelines = fields.String(allow_none=True) + remediation_guidelines = fields.String(allow_none=True) + description = fields.String(allow_none=True) @post_load def build_dto(self, data: Dict[str, Any], **_) -> DetectionRule: From baf376b901c645f1a2f77c009c9286266f0422ff Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 10 Apr 2024 12:07:13 +0200 Subject: [PATCH 084/257] CM-34895 - Add debug option to export in-memory zip archive on disk (#219) --- cycode/cli/consts.py | 1 + cycode/cli/files_collector/models/in_memory_zip.py | 12 +++++++++++- cycode/cli/files_collector/zip_documents.py | 6 ++++++ cycode/cli/user_settings/configuration_manager.py | 7 +++++++ 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index bbae235b..e6a0535b 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -116,6 +116,7 @@ CYCODE_CLI_REQUEST_TIMEOUT_ENV_VAR_NAME = 'CYCODE_CLI_REQUEST_TIMEOUT' LOGGING_LEVEL_ENV_VAR_NAME = 'LOGGING_LEVEL' VERBOSE_ENV_VAR_NAME = 'CYCODE_CLI_VERBOSE' +DEBUG_ENV_VAR_NAME = 'CYCODE_CLI_DEBUG' CYCODE_CONFIGURATION_DIRECTORY: str = '.cycode' diff --git a/cycode/cli/files_collector/models/in_memory_zip.py b/cycode/cli/files_collector/models/in_memory_zip.py index 410d00ca..a0700f6b 100644 --- a/cycode/cli/files_collector/models/in_memory_zip.py +++ b/cycode/cli/files_collector/models/in_memory_zip.py @@ -1,13 +1,19 @@ from io import BytesIO from sys import getsizeof -from typing import Optional +from typing import TYPE_CHECKING, Optional from zipfile import ZIP_DEFLATED, ZipFile +from cycode.cli.user_settings.configuration_manager import ConfigurationManager from cycode.cli.utils.path_utils import concat_unique_id +if TYPE_CHECKING: + from pathlib import Path + class InMemoryZip(object): def __init__(self) -> None: + self.configuration_manager = ConfigurationManager() + # Create the in-memory file-like object self.in_memory_zip = BytesIO() self.zip = ZipFile(self.in_memory_zip, 'a', ZIP_DEFLATED, False) @@ -27,6 +33,10 @@ def read(self) -> bytes: self.in_memory_zip.seek(0) return self.in_memory_zip.read() + def write_on_disk(self, path: 'Path') -> None: + with open(path, 'wb') as f: + f.write(self.read()) + @property def size(self) -> int: return getsizeof(self.in_memory_zip) diff --git a/cycode/cli/files_collector/zip_documents.py b/cycode/cli/files_collector/zip_documents.py index b2b252f4..97f42a30 100644 --- a/cycode/cli/files_collector/zip_documents.py +++ b/cycode/cli/files_collector/zip_documents.py @@ -1,4 +1,5 @@ import time +from pathlib import Path from typing import List, Optional from cycode.cli import consts @@ -37,4 +38,9 @@ def zip_documents(scan_type: str, documents: List[Document], zip_file: Optional[ zip_creation_time = int(end_zip_creation_time - start_zip_creation_time) logger.debug('finished to create zip file, %s', {'zip_creation_time': zip_creation_time}) + if zip_file.configuration_manager.get_debug_flag(): + zip_file_path = Path.joinpath(Path.cwd(), f'{scan_type}_scan_{end_zip_creation_time}.zip') + logger.debug('writing zip file to disk, %s', {'zip_file_path': zip_file_path}) + zip_file.write_on_disk(zip_file_path) + return zip_file diff --git a/cycode/cli/user_settings/configuration_manager.py b/cycode/cli/user_settings/configuration_manager.py index 4ceec970..ff39470a 100644 --- a/cycode/cli/user_settings/configuration_manager.py +++ b/cycode/cli/user_settings/configuration_manager.py @@ -46,6 +46,13 @@ def get_cycode_app_url(self) -> str: return consts.DEFAULT_CYCODE_APP_URL + def get_debug_flag_from_environment_variables(self) -> bool: + value = self._get_value_from_environment_variables(consts.DEBUG_ENV_VAR_NAME, '') + return value.lower() in {'true', '1'} + + def get_debug_flag(self) -> bool: + return self.get_debug_flag_from_environment_variables() + def get_verbose_flag(self) -> bool: verbose_flag_env_var = self.get_verbose_flag_from_environment_variables() verbose_flag_local_config = self.local_config_file_manager.get_verbose_flag() From 389f14bbea45b5e11b503727f58c7a9e3fce4f17 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Apr 2024 12:48:48 +0200 Subject: [PATCH 085/257] Bump idna from 3.6 to 3.7 (#221) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index 661b39b7..33f5470c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. [[package]] name = "altgraph" @@ -322,13 +322,13 @@ test = ["black", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre [[package]] name = "idna" -version = "3.6" +version = "3.7" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.5" files = [ - {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, - {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, + {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, + {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, ] [[package]] @@ -590,6 +590,7 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -597,8 +598,16 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -615,6 +624,7 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -622,6 +632,7 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, From 9b36b5151e1d6ae40eef404f9e6e44c6a1a3bafc Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Fri, 12 Apr 2024 14:50:29 +0200 Subject: [PATCH 086/257] CM-34777 - Add correlation ID (#220) --- cycode/cli/printers/printer_base.py | 4 ++ cycode/cyclient/config.py | 36 ++++++++------- cycode/cyclient/cycode_client_base.py | 26 ++--------- cycode/cyclient/headers.py | 46 +++++++++++++++++++ .../cli/exceptions/test_handle_scan_errors.py | 2 +- tests/cyclient/test_client_base.py | 4 +- 6 files changed, 78 insertions(+), 40 deletions(-) create mode 100644 cycode/cyclient/headers.py diff --git a/cycode/cli/printers/printer_base.py b/cycode/cli/printers/printer_base.py index 97a0aff5..cc354082 100644 --- a/cycode/cli/printers/printer_base.py +++ b/cycode/cli/printers/printer_base.py @@ -5,6 +5,7 @@ import click from cycode.cli.models import CliError, CliResult +from cycode.cyclient.headers import get_correlation_id if TYPE_CHECKING: from cycode.cli.models import LocalScanResult @@ -46,3 +47,6 @@ def print_exception(self, e: Optional[BaseException] = None) -> None: message = f'Error: {traceback_message}' click.secho(message, err=True, fg=self.RED_COLOR_NAME) + + correlation_message = f'Correlation ID: {get_correlation_id()}' + click.secho(correlation_message, err=True, fg=self.RED_COLOR_NAME) diff --git a/cycode/cyclient/config.py b/cycode/cyclient/config.py index da3c6a18..ade271d1 100644 --- a/cycode/cyclient/config.py +++ b/cycode/cyclient/config.py @@ -1,12 +1,12 @@ import logging import os import sys -from typing import Optional +from typing import Optional, Union from urllib.parse import urlparse from cycode.cli import consts from cycode.cli.user_settings.configuration_manager import ConfigurationManager -from cycode.cyclient.config_dev import DEV_MODE_ENV_VAR_NAME, DEV_TENANT_ID_ENV_VAR_NAME +from cycode.cyclient import config_dev def _set_io_encodings() -> None: @@ -37,7 +37,7 @@ def _set_io_encodings() -> None: DEFAULT_CONFIGURATION = { consts.TIMEOUT_ENV_VAR_NAME: 300, consts.LOGGING_LEVEL_ENV_VAR_NAME: logging.INFO, - DEV_MODE_ENV_VAR_NAME: 'False', + config_dev.DEV_MODE_ENV_VAR_NAME: 'false', } configuration = dict(DEFAULT_CONFIGURATION, **os.environ) @@ -45,12 +45,14 @@ def _set_io_encodings() -> None: _CREATED_LOGGERS = set() -def get_logger(logger_name: Optional[str] = None) -> logging.Logger: - config_level = _get_val_as_string(consts.LOGGING_LEVEL_ENV_VAR_NAME) - level = logging.getLevelName(config_level) +def get_logger_level() -> Optional[Union[int, str]]: + config_level = get_val_as_string(consts.LOGGING_LEVEL_ENV_VAR_NAME) + return logging.getLevelName(config_level) + +def get_logger(logger_name: Optional[str] = None) -> logging.Logger: new_logger = logging.getLogger(logger_name) - new_logger.setLevel(level) + new_logger.setLevel(get_logger_level()) _CREATED_LOGGERS.add(new_logger) @@ -62,16 +64,16 @@ def set_logging_level(level: int) -> None: created_logger.setLevel(level) -def _get_val_as_string(key: str) -> str: +def get_val_as_string(key: str) -> str: return configuration.get(key) -def _get_val_as_bool(key: str, default: str = '') -> bool: +def get_val_as_bool(key: str, default: str = '') -> bool: val = configuration.get(key, default) - return val.lower() in ('true', '1') + return val.lower() in {'true', '1'} -def _get_val_as_int(key: str) -> Optional[int]: +def get_val_as_int(key: str) -> Optional[int]: val = configuration.get(key) if val: return int(val) @@ -79,7 +81,7 @@ def _get_val_as_int(key: str) -> Optional[int]: return None -def _is_valid_url(url: str) -> bool: +def is_valid_url(url: str) -> bool: try: urlparse(url) return True @@ -92,12 +94,12 @@ def _is_valid_url(url: str) -> bool: configuration_manager = ConfigurationManager() cycode_api_url = configuration_manager.get_cycode_api_url() -if not _is_valid_url(cycode_api_url): +if not is_valid_url(cycode_api_url): cycode_api_url = consts.DEFAULT_CYCODE_API_URL -timeout = _get_val_as_int(consts.CYCODE_CLI_REQUEST_TIMEOUT_ENV_VAR_NAME) +timeout = get_val_as_int(consts.CYCODE_CLI_REQUEST_TIMEOUT_ENV_VAR_NAME) if not timeout: - timeout = _get_val_as_int(consts.TIMEOUT_ENV_VAR_NAME) + timeout = get_val_as_int(consts.TIMEOUT_ENV_VAR_NAME) -dev_mode = _get_val_as_bool(DEV_MODE_ENV_VAR_NAME) -dev_tenant_id = _get_val_as_string(DEV_TENANT_ID_ENV_VAR_NAME) +dev_mode = get_val_as_bool(config_dev.DEV_MODE_ENV_VAR_NAME) +dev_tenant_id = get_val_as_string(config_dev.DEV_TENANT_ID_ENV_VAR_NAME) diff --git a/cycode/cyclient/cycode_client_base.py b/cycode/cyclient/cycode_client_base.py index 6cfcd8c3..a4c5ab63 100644 --- a/cycode/cyclient/cycode_client_base.py +++ b/cycode/cyclient/cycode_client_base.py @@ -1,33 +1,17 @@ -import platform from typing import ClassVar, Dict, Optional from requests import Response, exceptions, request -from cycode import __version__ from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError, NetworkError -from cycode.cli.user_settings.configuration_manager import ConfigurationManager from cycode.cyclient import config, logger - - -def get_cli_user_agent() -> str: - """Return base User-Agent of CLI. - - Example: CycodeCLI/0.2.3 (OS: Darwin; Arch: arm64; Python: 3.8.16; InstallID: *uuid4*) - """ - app_name = 'CycodeCLI' - version = __version__ - - os = platform.system() - arch = platform.machine() - python_version = platform.python_version() - - install_id = ConfigurationManager().get_or_create_installation_id() - - return f'{app_name}/{version} (OS: {os}; Arch: {arch}; Python: {python_version}; InstallID: {install_id})' +from cycode.cyclient.headers import get_cli_user_agent, get_correlation_id class CycodeClientBase: - MANDATORY_HEADERS: ClassVar[Dict[str, str]] = {'User-Agent': get_cli_user_agent()} + MANDATORY_HEADERS: ClassVar[Dict[str, str]] = { + 'User-Agent': get_cli_user_agent(), + 'X-Correlation-Id': get_correlation_id(), + } def __init__(self, api_url: str) -> None: self.timeout = config.timeout diff --git a/cycode/cyclient/headers.py b/cycode/cyclient/headers.py new file mode 100644 index 00000000..53eba880 --- /dev/null +++ b/cycode/cyclient/headers.py @@ -0,0 +1,46 @@ +import platform +from typing import Optional +from uuid import uuid4 + +from cycode import __version__ +from cycode.cli.user_settings.configuration_manager import ConfigurationManager +from cycode.cyclient import logger + + +def get_cli_user_agent() -> str: + """Return base User-Agent of CLI. + + Example: CycodeCLI/0.2.3 (OS: Darwin; Arch: arm64; Python: 3.8.16; InstallID: *uuid4*) + """ + app_name = 'CycodeCLI' + version = __version__ + + os = platform.system() + arch = platform.machine() + python_version = platform.python_version() + + install_id = ConfigurationManager().get_or_create_installation_id() + + return f'{app_name}/{version} (OS: {os}; Arch: {arch}; Python: {python_version}; InstallID: {install_id})' + + +class _CorrelationId: + _id: Optional[str] = None + + def get_correlation_id(self) -> str: + """Get correlation ID. + + Notes: + Used across all requests to correlate logs and metrics. + It doesn't depend on client instances. + Lifetime is the same as the process. + """ + if self._id is None: + # example: 16fd2706-8baf-433b-82eb-8c7fada847da + self._id = str(uuid4()) + logger.debug(f'Correlation ID: {self._id}') + + return self._id + + +get_correlation_id = _CorrelationId().get_correlation_id diff --git a/tests/cli/exceptions/test_handle_scan_errors.py b/tests/cli/exceptions/test_handle_scan_errors.py index 7d63802b..d473801f 100644 --- a/tests/cli/exceptions/test_handle_scan_errors.py +++ b/tests/cli/exceptions/test_handle_scan_errors.py @@ -59,7 +59,7 @@ def test_handle_exception_verbose(monkeypatch: 'MonkeyPatch') -> None: ctx = click.Context(click.Command('path'), obj={'verbose': True, 'output': 'text'}) def mock_secho(msg: str, *_, **__) -> None: - assert 'Error:' in msg + assert 'Error:' in msg or 'Correlation ID:' in msg monkeypatch.setattr(click, 'secho', mock_secho) diff --git a/tests/cyclient/test_client_base.py b/tests/cyclient/test_client_base.py index d0b00563..d9e871d1 100644 --- a/tests/cyclient/test_client_base.py +++ b/tests/cyclient/test_client_base.py @@ -1,10 +1,12 @@ from cycode.cyclient import config -from cycode.cyclient.cycode_client_base import CycodeClientBase, get_cli_user_agent +from cycode.cyclient.cycode_client_base import CycodeClientBase +from cycode.cyclient.headers import get_cli_user_agent, get_correlation_id def test_mandatory_headers() -> None: expected_headers = { 'User-Agent': get_cli_user_agent(), + 'X-Correlation-Id': get_correlation_id(), } client = CycodeClientBase(config.cycode_api_url) From 9693a03e5f7cdb54cf9f51be30c31d76a9e3dbc4 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Fri, 10 May 2024 15:37:34 +0200 Subject: [PATCH 087/257] CM-35810 - Soft deprecate support for Python 3.7 (#224) --- .github/workflows/pre_release.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- .github/workflows/ruff.yml | 2 +- .github/workflows/tests.yml | 2 +- .github/workflows/tests_full.yml | 2 +- README.md | 5 +++++ 6 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 342d59bc..d070ec19 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -31,10 +31,10 @@ jobs: with: fetch-depth: 0 - - name: Set up Python 3.7 + - name: Set up Python 3.8 uses: actions/setup-python@v4 with: - python-version: '3.7' + python-version: '3.8' - name: Load cached Poetry setup id: cached-poetry diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b578f6b6..f38a085f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,10 +30,10 @@ jobs: with: fetch-depth: 0 - - name: Set up Python 3.7 + - name: Set up Python 3.8 uses: actions/setup-python@v4 with: - python-version: '3.7' + python-version: '3.8' - name: Load cached Poetry setup id: cached-poetry diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index a7f86c2b..29e43c2d 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -23,7 +23,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.8 - name: Load cached Poetry setup id: cached-poetry diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index feccb486..114169e4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,7 +27,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.7' + python-version: '3.8' - name: Load cached Poetry setup id: cached-poetry diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index 8225b0c3..524bd22b 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: os: [ macos-latest, ubuntu-latest, windows-latest ] - python-version: [ "3.7", "3.8", "3.9", "3.10", "3.11", "3.12" ] + python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ] runs-on: ${{matrix.os}} diff --git a/README.md b/README.md index 7077e695..1b06b0b3 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,11 @@ This guide will guide you through both installation and usage. # Prerequisites +> [!WARNING] +> Python 3.7 end-of-life was on 2023-06-27. +> It is recommended to use Python 3.8 or later. +> We will drop support for Python 3.7 soon. + - The Cycode CLI application requires Python version 3.7 or later. - Use the [`cycode auth` command](#using-the-auth-command) to authenticate to Cycode with the CLI - Alternatively, you can obtain a Cycode Client ID and Client Secret Key by following the steps detailed in the [Service Account Token](https://docs.cycode.com/reference/creating-a-service-account-access-token) and [Personal Access Token](https://docs.cycode.com/reference/creating-a-personal-access-token-1) pages, which contain details on obtaining these values. From 02bdd085d365509ab33f352dfece8c46d06a0b03 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Fri, 10 May 2024 16:09:07 +0200 Subject: [PATCH 088/257] CM-35811 - Change GitHub Actions runner for building CLI for ARM macOS (#225) --- .github/workflows/build_executable.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index dcf2a42b..0849871e 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ ubuntu-20.04, macos-11, macos-13-xlarge, windows-2019 ] + os: [ ubuntu-20.04, macos-12, macos-14, windows-2019 ] mode: [ 'onefile', 'onedir' ] exclude: - os: ubuntu-20.04 @@ -142,6 +142,7 @@ jobs: - name: Test macOS signed executable if: runner.os == 'macOS' run: | + file -b $PATH_TO_CYCODE_CLI_EXECUTABLE time $PATH_TO_CYCODE_CLI_EXECUTABLE version # verify signature From 7027e2d17de1bb3e963623f3d29b251f53e645a4 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Mon, 13 May 2024 14:28:20 +0200 Subject: [PATCH 089/257] CM-31709 - Migrate to the new filter to fetch detection rules (#222) --- cycode/cli/commands/scan/code_scanner.py | 15 +++++--------- cycode/cyclient/models.py | 3 +-- cycode/cyclient/scan_client.py | 26 ++++-------------------- 3 files changed, 10 insertions(+), 34 deletions(-) diff --git a/cycode/cli/commands/scan/code_scanner.py b/cycode/cli/commands/scan/code_scanner.py index dacec86e..13defe0f 100644 --- a/cycode/cli/commands/scan/code_scanner.py +++ b/cycode/cli/commands/scan/code_scanner.py @@ -116,19 +116,14 @@ def _should_use_sync_flow(scan_type: str, sync_option: bool, scan_parameters: Op def _enrich_scan_result_with_data_from_detection_rules( - cycode_client: 'ScanClient', scan_type: str, scan_result: ZippedFileScanResult + cycode_client: 'ScanClient', scan_result: ZippedFileScanResult ) -> None: - # TODO(MarshalX): remove scan_type arg after migration to new backend filter - if scan_type not in {consts.SECRET_SCAN_TYPE, consts.INFRA_CONFIGURATION_SCAN_TYPE}: - # not yet - return - detection_rule_ids = set() for detections_per_file in scan_result.detections_per_file: for detection in detections_per_file.detections: detection_rule_ids.add(detection.detection_rule_id) - detection_rules = cycode_client.get_detection_rules(scan_type, detection_rule_ids) + detection_rules = cycode_client.get_detection_rules(detection_rule_ids) detection_rules_by_id = {detection_rule.detection_rule_id: detection_rule for detection_rule in detection_rules} for detections_per_file in scan_result.detections_per_file: @@ -138,9 +133,9 @@ def _enrich_scan_result_with_data_from_detection_rules( # we want to make sure that BE returned it. better to not map data instead of failed scan continue - if detection_rule.classification_data: + if not detection.severity and detection_rule.classification_data: # it's fine to take the first one, because: - # - for "secrets" and "iac" there is only one classification rule per detection rule + # - for "secrets" and "iac" there is only one classification rule per-detection rule # - for "sca" and "sast" we get severity from detection service detection.severity = detection_rule.classification_data[0].severity @@ -187,7 +182,7 @@ def _scan_batch_thread_func(batch: List[Document]) -> Tuple[str, CliError, Local should_use_sync_flow, ) - _enrich_scan_result_with_data_from_detection_rules(cycode_client, scan_type, scan_result) + _enrich_scan_result_with_data_from_detection_rules(cycode_client, scan_result) local_scan_result = create_local_scan_result( scan_result, batch, command_scan_type, scan_type, severity_threshold diff --git a/cycode/cyclient/models.py b/cycode/cyclient/models.py index c6c0c2a3..8f54ee24 100644 --- a/cycode/cyclient/models.py +++ b/cycode/cyclient/models.py @@ -38,8 +38,7 @@ class Meta: message = fields.String() type = fields.String() - severity = fields.String(missing='High') - # TODO(MarshalX): Remove "missing" arg when IaC and Secrets scans will have classifications + severity = fields.String(missing=None) detection_type_id = fields.String() detection_details = fields.Dict() detection_rule_id = fields.String() diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index c2207c9b..e3e2c85b 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -164,7 +164,7 @@ def get_detection_rules_path(self) -> str: return ( f'{self.scan_config.get_detections_prefix()}/' f'{self.POLICIES_SERVICE_CONTROLLER_PATH_V3}/' - f'detection_rules' + f'detection_rules/byIds' ) @staticmethod @@ -181,36 +181,18 @@ def _get_policy_type_by_scan_type(scan_type: str) -> str: return scan_type_to_policy_type[scan_type] - @staticmethod - def _filter_detection_rules_by_ids( - detection_rules: List[models.DetectionRule], detection_rules_ids: Union[Set[str], List[str]] - ) -> List[models.DetectionRule]: - ids = set(detection_rules_ids) # cast to set to perform faster search - return [rule for rule in detection_rules if rule.detection_rule_id in ids] - @staticmethod def parse_detection_rules_response(response: Response) -> List[models.DetectionRule]: return models.DetectionRuleSchema().load(response.json(), many=True) - def get_detection_rules( - self, scan_type: str, detection_rules_ids: Union[Set[str], List[str]] - ) -> List[models.DetectionRule]: - # TODO(MarshalX): use filter by list of IDs instead of policy_type when BE will be ready - params = { - 'include_hidden': False, - 'include_only_enabled_detection_rules': True, - 'page_number': 0, - 'page_size': 5000, - 'policy_types_v2': self._get_policy_type_by_scan_type(scan_type), - } + def get_detection_rules(self, detection_rules_ids: Union[Set[str], List[str]]) -> List[models.DetectionRule]: response = self.scan_cycode_client.get( url_path=self.get_detection_rules_path(), - params=params, + params={'ids': detection_rules_ids}, hide_response_content_log=self._hide_response_log, ) - # we are filtering rules by ids in-place for smooth migration when backend will be ready - return self._filter_detection_rules_by_ids(self.parse_detection_rules_response(response), detection_rules_ids) + return self.parse_detection_rules_response(response) def get_scan_detections_path(self, scan_type: str) -> str: return f'{self.scan_config.get_detections_prefix()}/{self.get_detections_service_controller_path(scan_type)}' From 10d726f02b5f5bb8698bec62ed058732763c0167 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Mon, 13 May 2024 15:22:00 +0200 Subject: [PATCH 090/257] CM-35356 - Add titles for SAST detections (#226) --- cycode/cli/commands/scan/code_scanner.py | 1 + cycode/cyclient/models.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/cycode/cli/commands/scan/code_scanner.py b/cycode/cli/commands/scan/code_scanner.py index 13defe0f..fa47c6e3 100644 --- a/cycode/cli/commands/scan/code_scanner.py +++ b/cycode/cli/commands/scan/code_scanner.py @@ -143,6 +143,7 @@ def _enrich_scan_result_with_data_from_detection_rules( detection.detection_details['custom_remediation_guidelines'] = detection_rule.custom_remediation_guidelines detection.detection_details['remediation_guidelines'] = detection_rule.remediation_guidelines detection.detection_details['description'] = detection_rule.description + detection.detection_details['policy_display_name'] = detection_rule.display_name def _get_scan_documents_thread_func( diff --git a/cycode/cyclient/models.py b/cycode/cyclient/models.py index 8f54ee24..894e6444 100644 --- a/cycode/cyclient/models.py +++ b/cycode/cyclient/models.py @@ -441,6 +441,8 @@ class DetectionRule: custom_remediation_guidelines: Optional[str] = None remediation_guidelines: Optional[str] = None description: Optional[str] = None + policy_name: Optional[str] = None + display_name: Optional[str] = None class DetectionRuleSchema(Schema): @@ -452,6 +454,8 @@ class Meta: custom_remediation_guidelines = fields.String(allow_none=True) remediation_guidelines = fields.String(allow_none=True) description = fields.String(allow_none=True) + policy_name = fields.String(allow_none=True) + display_name = fields.String(allow_none=True) @post_load def build_dto(self, data: Dict[str, Any], **_) -> DetectionRule: From 8b9e47205ac68c70314227dd297f0a520f195a3e Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Mon, 13 May 2024 15:24:30 +0200 Subject: [PATCH 091/257] CM-35804 - Increase file size limit from 1MB to 5MB (#223) --- cycode/cli/consts.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index e6a0535b..132b45b9 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -127,8 +127,8 @@ EXCLUSIONS_BY_RULE_SECTION_NAME = 'rules' EXCLUSIONS_BY_PACKAGE_SECTION_NAME = 'packages' -# 1MB in bytes (in decimal) -FILE_MAX_SIZE_LIMIT_IN_BYTES = 1000000 +# 5MB in bytes (in decimal) +FILE_MAX_SIZE_LIMIT_IN_BYTES = 5000000 # 20MB in bytes (in binary) ZIP_MAX_SIZE_LIMIT_IN_BYTES = 20971520 From 099f4a66cd4a07343e14afa611aa413abd3c19d7 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Mon, 20 May 2024 11:39:20 +0200 Subject: [PATCH 092/257] CM-35702 - Update the "Soft Fail" documentation (#227) Co-authored-by: elsapet --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1b06b0b3..32d8eb49 100644 --- a/README.md +++ b/README.md @@ -519,9 +519,12 @@ Secret SHA: a44081db3296c84b82d12a35c446a3cba19411dddfa0380134c75f7b3973bff0 ### Soft Fail -Utilizing the soft fail feature will not fail the CI/CD step within the pipeline if the Cycode scan finds an issue. Additionally, in case an issue occurs from Cycode’s side, a soft fail will automatically execute to avoid interference. +Using the soft fail feature will not fail the CI/CD step within the pipeline if the Cycode scan detects an issue. +If an issue occurs during the Cycode scan, using a soft fail feature will automatically execute with success (`0`) to avoid interference. -Add the `--soft-fail` argument to any type of scan to configure this feature, then assign a value of `1` if you want found issues to result in a failure within the CI/CD tool or `0` for scan results to have no impact (result in a `success` result). +To configure this feature, add the `--soft-fail` option to any type of scan. This will force the scan results to succeed (exit code `0`). + +Scan results are assigned with a value of exit code `1` when issues are found in the scan results; this will result in a failure within the CI/CD tool. Use the option `--soft-fail` to force the results with the exit code `0` to have no impact (i.e., to have a successful result). ### Example Scan Results From 1c46a554201cea22ad16d592512f0b958c9f6e85 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Mon, 27 May 2024 11:33:19 +0200 Subject: [PATCH 093/257] CM-35812 - Improve logging by formatting and providing more info (#229) Co-authored-by: elsapet --- cycode/cli/commands/auth/auth_manager.py | 22 +++---- cycode/cli/commands/report/sbom/common.py | 2 +- cycode/cli/commands/scan/code_scanner.py | 66 +++++++++++-------- .../scan/pre_receive/pre_receive_command.py | 9 +-- cycode/cli/files_collector/excluder.py | 49 ++++++++++---- cycode/cli/files_collector/path_documents.py | 2 +- .../maven/base_restore_maven_dependencies.py | 2 +- .../files_collector/sca/sca_code_scanner.py | 12 ++-- cycode/cli/files_collector/zip_documents.py | 13 ++-- cycode/cli/utils/progress_bar.py | 17 +++-- cycode/cli/utils/shell_executor.py | 4 +- cycode/cyclient/config.py | 27 +++++--- cycode/cyclient/cycode_client_base.py | 10 ++- cycode/cyclient/headers.py | 2 +- pyproject.toml | 1 + tests/test_performance_get_all_files.py | 8 +-- 16 files changed, 152 insertions(+), 94 deletions(-) diff --git a/cycode/cli/commands/auth/auth_manager.py b/cycode/cli/commands/auth/auth_manager.py index 829164c2..ab621842 100644 --- a/cycode/cli/commands/auth/auth_manager.py +++ b/cycode/cli/commands/auth/auth_manager.py @@ -29,20 +29,20 @@ def __init__(self) -> None: self.auth_client = AuthClient() def authenticate(self) -> None: - logger.debug('generating pkce code pair') + logger.debug('Generating PKCE code pair') code_challenge, code_verifier = self._generate_pkce_code_pair() - logger.debug('starting authentication session') + logger.debug('Starting authentication session') session_id = self.start_session(code_challenge) - logger.debug('authentication session created, %s', {'session_id': session_id}) + logger.debug('Authentication session created, %s', {'session_id': session_id}) - logger.debug('opening browser and redirecting to cycode login page') + logger.debug('Opening browser and redirecting to Cycode login page') self.redirect_to_login_page(code_challenge, session_id) - logger.debug('starting get api token process') + logger.debug('Getting API token') api_token = self.get_api_token(session_id, code_verifier) - logger.debug('saving get api token') + logger.debug('Saving API token') self.save_api_token(api_token) def start_session(self, code_challenge: str) -> str: @@ -56,20 +56,20 @@ def redirect_to_login_page(self, code_challenge: str, session_id: str) -> None: def get_api_token(self, session_id: str, code_verifier: str) -> 'ApiToken': api_token = self.get_api_token_polling(session_id, code_verifier) if api_token is None: - raise AuthProcessError('getting api token is completed, but the token is missing') + raise AuthProcessError('API token pulling is completed, but the token is missing') return api_token def get_api_token_polling(self, session_id: str, code_verifier: str) -> 'ApiToken': end_polling_time = time.time() + self.POLLING_TIMEOUT_IN_SECONDS while time.time() < end_polling_time: - logger.debug('trying to get api token...') + logger.debug('Trying to get API token...') api_token_polling_response = self.auth_client.get_api_token(session_id, code_verifier) if self._is_api_token_process_completed(api_token_polling_response): - logger.debug('get api token process completed') + logger.debug('Got API token process completion response') return api_token_polling_response.api_token if self._is_api_token_process_failed(api_token_polling_response): - logger.debug('get api token process failed') - raise AuthProcessError('error during getting api token') + logger.debug('Got API token process failure response') + raise AuthProcessError('Error while obtaining API token') time.sleep(self.POLLING_WAIT_INTERVAL_IN_SECONDS) raise AuthProcessError('session expired') diff --git a/cycode/cli/commands/report/sbom/common.py b/cycode/cli/commands/report/sbom/common.py index 334e7275..6ea843f5 100644 --- a/cycode/cli/commands/report/sbom/common.py +++ b/cycode/cli/commands/report/sbom/common.py @@ -70,7 +70,7 @@ def send_report_feedback( client.report_status(report_execution_id, scan_status) except Exception as e: - logger.debug(f'Failed to send report feedback: {e}') + logger.debug('Failed to send report feedback', exc_info=e) def create_sbom_report( diff --git a/cycode/cli/commands/scan/code_scanner.py b/cycode/cli/commands/scan/code_scanner.py index fa47c6e3..134fbbd4 100644 --- a/cycode/cli/commands/scan/code_scanner.py +++ b/cycode/cli/commands/scan/code_scanner.py @@ -200,7 +200,7 @@ def _scan_batch_thread_func(batch: List[Document]) -> Tuple[str, CliError, Local scan_id = local_scan_result.scan_id logger.debug( - 'Finished scan process, %s', + 'Processing scan results, %s', { 'all_violations_count': detections_count, 'relevant_violations_count': relevant_detections_count, @@ -246,14 +246,14 @@ def scan_commit_range( repo = Repo(path) total_commits_count = int(repo.git.rev_list('--count', commit_range)) - logger.debug(f'Calculating diffs for {total_commits_count} commits in the commit range {commit_range}') + logger.debug('Calculating diffs for %s commits in the commit range %s', total_commits_count, commit_range) progress_bar.set_section_length(ScanProgressBarSection.PREPARE_LOCAL_FILES, total_commits_count) scanned_commits_count = 0 for commit in repo.iter_commits(rev=commit_range): if _does_reach_to_max_commits_to_scan_limit(commit_ids_to_scan, max_commits_count): - logger.debug(f'Reached to max commits to scan count. Going to scan only {max_commits_count} last commits') + logger.debug('Reached to max commits to scan count. Going to scan only %s last commits', max_commits_count) progress_bar.update(ScanProgressBarSection.PREPARE_LOCAL_FILES, total_commits_count - scanned_commits_count) break @@ -284,7 +284,7 @@ def scan_commit_range( scanned_commits_count += 1 logger.debug('List of commit ids to scan, %s', {'commit_ids': commit_ids_to_scan}) - logger.debug('Starting to scan commit range (It may take a few minutes)') + logger.debug('Starting to scan commit range (it may take a few minutes)') scan_documents(context, documents_to_scan, is_git_diff=True, is_commit_range=True) return None @@ -307,7 +307,8 @@ def scan_documents( ConsolePrinter(context).print_error( CliError( code='no_relevant_files', - message='Error: The scan could not be completed - relevant files to scan are not found.', + message='Error: The scan could not be completed - relevant files to scan are not found. ' + 'Enable verbose mode to see more details.', ) ) return @@ -392,7 +393,7 @@ def scan_commit_range_documents( scan_id = local_scan_result.scan_id logger.debug( - 'Finished scan process, %s', + 'Processing commit range scan results, %s', { 'all_violations_count': detections_count, 'relevant_violations_count': relevant_detections_count, @@ -475,7 +476,7 @@ def perform_scan_async( scan_parameters: dict, ) -> ZippedFileScanResult: scan_async_result = cycode_client.zipped_file_scan_async(zipped_documents, scan_type, scan_parameters) - logger.debug('scan request has been triggered successfully, scan id: %s', scan_async_result.scan_id) + logger.debug('Async scan request has been triggered successfully, %s', {'scan_id': scan_async_result.scan_id}) return poll_scan_results( cycode_client, @@ -492,7 +493,7 @@ def perform_scan_sync( scan_parameters: dict, ) -> ZippedFileScanResult: scan_results = cycode_client.zipped_file_scan_sync(zipped_documents, scan_type, scan_parameters) - logger.debug('scan request has been triggered successfully, scan id: %s', scan_results.id) + logger.debug('Sync scan request has been triggered successfully, %s', {'scan_id': scan_results.id}) return ZippedFileScanResult( did_detect=True, detections_per_file=_map_detections_per_file(scan_results.detection_messages), @@ -512,7 +513,9 @@ def perform_commit_range_scan_async( from_commit_zipped_documents, to_commit_zipped_documents, scan_type, scan_parameters ) - logger.debug('scan request has been triggered successfully, scan id: %s', scan_async_result.scan_id) + logger.debug( + 'Async commit range scan request has been triggered successfully, %s', {'scan_id': scan_async_result.scan_id} + ) return poll_scan_results( cycode_client, scan_async_result.scan_id, scan_type, scan_parameters.get('report'), timeout ) @@ -552,11 +555,12 @@ def poll_scan_results( def print_debug_scan_details(scan_details_response: 'ScanDetailsResponse') -> None: - logger.debug(f'Scan update: (scan_id: {scan_details_response.id})') - logger.debug(f'Scan status: {scan_details_response.scan_status}') + logger.debug( + 'Scan update, %s', {'scan_id': scan_details_response.id, 'scan_status': scan_details_response.scan_status} + ) if scan_details_response.message: - logger.debug(f'Scan message: {scan_details_response.message}') + logger.debug('Scan message: %s', scan_details_response.message) def print_results( @@ -569,14 +573,16 @@ def print_results( def get_document_detections( scan_result: ZippedFileScanResult, documents_to_scan: List[Document] ) -> List[DocumentDetections]: - logger.debug('Get document detections') + logger.debug('Getting document detections') document_detections = [] for detections_per_file in scan_result.detections_per_file: file_name = get_path_by_os(detections_per_file.file_name) commit_id = detections_per_file.commit_id - logger.debug('Going to find document of violated file, %s', {'file_name': file_name, 'commit_id': commit_id}) + logger.debug( + 'Going to find the document of the violated file., %s', {'file_name': file_name, 'commit_id': commit_id} + ) document = _get_document_by_file_name(documents_to_scan, file_name, commit_id) document_detections.append(DocumentDetections(document=document, detections=detections_per_file.detections)) @@ -659,10 +665,10 @@ def get_scan_parameters(context: click.Context, paths: Tuple[str]) -> dict: def try_get_git_remote_url(path: str) -> Optional[str]: try: remote_url = Repo(path).remotes[0].config_reader.get('url') - logger.debug(f'Found Git remote URL "{remote_url}" in path "{path}"') + logger.debug('Found Git remote URL, %s', {'remote_url': remote_url, 'path': path}) return remote_url except Exception as e: - logger.debug('Failed to get git remote URL. %s', {'exception_message': str(e)}) + logger.debug('Failed to get Git remote URL', exc_info=e) return None @@ -719,15 +725,15 @@ def _should_exclude_detection(detection: Detection, exclusions: Dict) -> bool: exclusions_by_value = exclusions.get(consts.EXCLUSIONS_BY_VALUE_SECTION_NAME, []) if _is_detection_sha_configured_in_exclusions(detection, exclusions_by_value): logger.debug( - 'Going to ignore violations because is in the values to ignore list, %s', - {'sha': detection.detection_details.get('sha512', '')}, + 'Going to ignore violations because they are on the values-to-ignore list, %s', + {'value_sha': detection.detection_details.get('sha512', '')}, ) return True exclusions_by_sha = exclusions.get(consts.EXCLUSIONS_BY_SHA_SECTION_NAME, []) if _is_detection_sha_configured_in_exclusions(detection, exclusions_by_sha): logger.debug( - 'Going to ignore violations because is in the shas to ignore list, %s', + 'Going to ignore violations because they are on the SHA ignore list, %s', {'sha': detection.detection_details.get('sha512', '')}, ) return True @@ -737,7 +743,7 @@ def _should_exclude_detection(detection: Detection, exclusions: Dict) -> bool: detection_rule = detection.detection_rule_id if detection_rule in exclusions_by_rule: logger.debug( - 'Going to ignore violations because is in the shas to ignore list, %s', + 'Going to ignore violations because they are on the Rule ID ignore list, %s', {'detection_rule': detection_rule}, ) return True @@ -747,7 +753,7 @@ def _should_exclude_detection(detection: Detection, exclusions: Dict) -> bool: package = _get_package_name(detection) if package in exclusions_by_package: logger.debug( - 'Going to ignore violations because is in the packages to ignore list, %s', {'package': package} + 'Going to ignore violations because they are on the packages-to-ignore list, %s', {'package': package} ) return True @@ -810,7 +816,7 @@ def _report_scan_status( cycode_client.report_scan_status(scan_type, scan_id, scan_status, should_use_scan_service) except Exception as e: - logger.debug('Failed to report scan status, %s', {'exception_message': str(e)}) + logger.debug('Failed to report scan status', exc_info=e) def _generate_unique_id() -> UUID: @@ -868,7 +874,7 @@ def _try_get_report_url_if_needed( report_url_response = cycode_client.get_scan_report_url(scan_id, scan_type) return report_url_response.report_url except Exception as e: - logger.debug('Failed to get report url: %s', str(e)) + logger.debug('Failed to get report URL', exc_info=e) def wait_for_detections_creation( @@ -883,16 +889,18 @@ def wait_for_detections_creation( while time.time() < end_polling_time: scan_persisted_detections_count = cycode_client.get_scan_detections_count(scan_type, scan_id) logger.debug( - f'Excepted {expected_detections_count} detections, got {scan_persisted_detections_count} detections ' - f'({expected_detections_count - scan_persisted_detections_count} more; ' - f'{round(end_polling_time - time.time())} seconds left)' + 'Excepting %s detections, got %s detections (%s more; %s seconds left)', + expected_detections_count, + scan_persisted_detections_count, + expected_detections_count - scan_persisted_detections_count, + round(end_polling_time - time.time()), ) if scan_persisted_detections_count == expected_detections_count: return time.sleep(consts.DETECTIONS_COUNT_VERIFICATION_WAIT_INTERVAL_IN_SECONDS) - logger.debug(f'{scan_persisted_detections_count} detections has been created') + logger.debug('%s detections has been created', scan_persisted_detections_count) raise custom_exceptions.ScanAsyncError( f'Failed to wait for detections to be created after {polling_timeout} seconds' ) @@ -905,14 +913,14 @@ def _map_detections_per_file(detections: List[dict]) -> List[DetectionsPerFile]: detection['message'] = detection['correlation_message'] file_name = _get_file_name_from_detection(detection) if file_name is None: - logger.debug('file name is missing from detection with id %s', detection.get('id')) + logger.debug('File name is missing from detection with ID %s', detection.get('id')) continue if detections_per_files.get(file_name) is None: detections_per_files[file_name] = [DetectionSchema().load(detection)] else: detections_per_files[file_name].append(DetectionSchema().load(detection)) except Exception as e: - logger.debug('Failed to parse detection: %s', str(e)) + logger.debug('Failed to parse detection', exc_info=e) continue return [ 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 71cd82c1..8aa2dbc9 100644 --- a/cycode/cli/commands/scan/pre_receive/pre_receive_command.py +++ b/cycode/cli/commands/scan/pre_receive/pre_receive_command.py @@ -32,14 +32,14 @@ def pre_receive_command(context: click.Context, ignored_args: List[str]) -> None if should_skip_pre_receive_scan(): logger.info( - 'A scan has been skipped as per your request.' - ' Please note that this may leave your system vulnerable to secrets that have not been detected' + 'A scan has been skipped as per your request. ' + 'Please note that this may leave your system vulnerable to secrets that have not been detected.' ) return if is_verbose_mode_requested_in_pre_receive_scan(): enable_verbose_mode(context) - logger.debug('Verbose mode enabled, all log levels will be displayed') + logger.debug('Verbose mode enabled: all log levels will be displayed.') command_scan_type = context.info_name timeout = configuration_manager.get_pre_receive_command_timeout(command_scan_type) @@ -51,7 +51,8 @@ def pre_receive_command(context: click.Context, ignored_args: List[str]) -> None commit_range = calculate_pre_receive_commit_range(branch_update_details) if not commit_range: logger.info( - 'No new commits found for pushed branch, %s', {'branch_update_details': branch_update_details} + 'No new commits found for pushed branch, %s', + {'branch_update_details': branch_update_details}, ) return diff --git a/cycode/cli/files_collector/excluder.py b/cycode/cli/files_collector/excluder.py index cbbb358f..1e0eab8a 100644 --- a/cycode/cli/files_collector/excluder.py +++ b/cycode/cli/files_collector/excluder.py @@ -62,23 +62,36 @@ def _does_document_exceed_max_size_limit(content: str) -> bool: def _is_relevant_file_to_scan(scan_type: str, filename: str) -> bool: if _is_subpath_of_cycode_configuration_folder(filename): - logger.debug('file is irrelevant because it is in cycode configuration directory, %s', {'filename': filename}) + logger.debug( + 'The file is irrelevant because it is in the Cycode configuration directory, %s', + {'filename': filename, 'configuration_directory': consts.CYCODE_CONFIGURATION_DIRECTORY}, + ) return False if _is_path_configured_in_exclusions(scan_type, filename): - logger.debug('file is irrelevant because the file path is in the ignore paths list, %s', {'filename': filename}) + logger.debug('The file is irrelevant because its path is in the ignore paths list, %s', {'filename': filename}) return False if not _is_file_extension_supported(scan_type, filename): - logger.debug('file is irrelevant because the file extension is not supported, %s', {'filename': filename}) + logger.debug( + 'The file is irrelevant because its extension is not supported, %s', + {'scan_type': scan_type, 'filename': filename}, + ) return False if is_binary_file(filename): - logger.debug('file is irrelevant because it is binary file, %s', {'filename': filename}) + logger.debug('The file is irrelevant because it is a binary file, %s', {'filename': filename}) return False if scan_type != consts.SCA_SCAN_TYPE and _does_file_exceed_max_size_limit(filename): - logger.debug('file is irrelevant because its exceeded max size limit, %s', {'filename': filename}) + logger.debug( + 'The file is irrelevant because it has exceeded the maximum size limit, %s', + { + 'max_file_size': consts.FILE_MAX_SIZE_LIMIT_IN_BYTES, + 'file_size': get_file_size(filename), + 'filename': filename, + }, + ) return False if scan_type == consts.SCA_SCAN_TYPE and not _is_file_relevant_for_sca_scan(filename): @@ -89,7 +102,9 @@ def _is_relevant_file_to_scan(scan_type: str, filename: str) -> bool: def _is_file_relevant_for_sca_scan(filename: str) -> bool: if any(sca_excluded_path in filename for sca_excluded_path in consts.SCA_EXCLUDED_PATHS): - logger.debug("file is irrelevant because it is from node_modules's inner path, %s", {'filename': filename}) + logger.debug( + 'The file is irrelevant because it is from the inner path of node_modules, %s', {'filename': filename} + ) return False return True @@ -98,27 +113,39 @@ def _is_file_relevant_for_sca_scan(filename: str) -> bool: def _is_relevant_document_to_scan(scan_type: str, filename: str, content: str) -> bool: if _is_subpath_of_cycode_configuration_folder(filename): logger.debug( - 'document is irrelevant because it is in cycode configuration directory, %s', {'filename': filename} + 'The document is irrelevant because it is in the Cycode configuration directory, %s', + {'filename': filename, 'configuration_directory': consts.CYCODE_CONFIGURATION_DIRECTORY}, ) return False if _is_path_configured_in_exclusions(scan_type, filename): logger.debug( - 'document is irrelevant because the document path is in the ignore paths list, %s', {'filename': filename} + 'The document is irrelevant because its path is in the ignore paths list, %s', {'filename': filename} ) return False if not _is_file_extension_supported(scan_type, filename): - logger.debug('document is irrelevant because the file extension is not supported, %s', {'filename': filename}) + logger.debug( + 'The document is irrelevant because its extension is not supported, %s', + {'scan_type': scan_type, 'filename': filename}, + ) return False if is_binary_content(content): - logger.debug('document is irrelevant because it is binary, %s', {'filename': filename}) + logger.debug('The document is irrelevant because it is a binary file, %s', {'filename': filename}) return False if scan_type != consts.SCA_SCAN_TYPE and _does_document_exceed_max_size_limit(content): - logger.debug('document is irrelevant because its exceeded max size limit, %s', {'filename': filename}) + logger.debug( + 'The document is irrelevant because it has exceeded the maximum size limit, %s', + { + 'max_document_size': consts.FILE_MAX_SIZE_LIMIT_IN_BYTES, + 'document_size': get_content_size(content), + 'filename': filename, + }, + ) return False + return True diff --git a/cycode/cli/files_collector/path_documents.py b/cycode/cli/files_collector/path_documents.py index 1d16e4f9..ac63c9e3 100644 --- a/cycode/cli/files_collector/path_documents.py +++ b/cycode/cli/files_collector/path_documents.py @@ -74,7 +74,7 @@ def _get_relevant_files( progress_bar.set_section_length(progress_bar_section, progress_bar_section_len) logger.debug( - 'Found all relevant files for scanning %s', {'paths': paths, 'file_to_scan_count': len(relevant_files_to_scan)} + 'Found all relevant files for scanning, %s', {'paths': paths, 'file_to_scan_count': len(relevant_files_to_scan)} ) return relevant_files_to_scan diff --git a/cycode/cli/files_collector/sca/maven/base_restore_maven_dependencies.py b/cycode/cli/files_collector/sca/maven/base_restore_maven_dependencies.py index d15e1ef0..e302e3a0 100644 --- a/cycode/cli/files_collector/sca/maven/base_restore_maven_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/base_restore_maven_dependencies.py @@ -17,7 +17,7 @@ def execute_command(command: List[str], file_name: str, command_timeout: int) -> try: dependencies = shell(command, command_timeout) except Exception as e: - logger.debug('Failed to restore dependencies shell comment. %s', {'filename': file_name, 'exception': str(e)}) + logger.debug('Failed to restore dependencies via shell command, %s', {'filename': file_name}, exc_info=e) return None return dependencies diff --git a/cycode/cli/files_collector/sca/sca_code_scanner.py b/cycode/cli/files_collector/sca/sca_code_scanner.py index a6aa6b78..4c2139e7 100644 --- a/cycode/cli/files_collector/sca/sca_code_scanner.py +++ b/cycode/cli/files_collector/sca/sca_code_scanner.py @@ -47,7 +47,7 @@ def add_ecosystem_related_files_if_exists( for doc in documents: ecosystem = get_project_file_ecosystem(doc) if ecosystem is None: - logger.debug('failed to resolve project file ecosystem: %s', doc.path) + logger.debug('Failed to resolve project file ecosystem: %s', doc.path) continue documents_to_add.extend(get_doc_ecosystem_related_project_files(doc, documents, ecosystem, commit_rev, repo)) @@ -94,20 +94,20 @@ def try_restore_dependencies( if restore_dependencies.is_project(document): restore_dependencies_document = restore_dependencies.restore(document) if restore_dependencies_document is None: - logger.warning('Error occurred while trying to generate dependencies tree. %s', {'filename': document.path}) + logger.warning('Error occurred while trying to generate dependencies tree, %s', {'filename': document.path}) return if restore_dependencies_document.content is None: - logger.warning('Error occurred while trying to generate dependencies tree. %s', {'filename': document.path}) + logger.warning('Error occurred while trying to generate dependencies tree, %s', {'filename': document.path}) restore_dependencies_document.content = '' else: is_monitor_action = context.obj.get('monitor') project_path = context.params.get('path') manifest_file_path = get_manifest_file_path(document, is_monitor_action, project_path) - logger.debug(f'Succeeded to generate dependencies tree on path: {manifest_file_path}') + logger.debug('Succeeded to generate dependencies tree on path: %s', manifest_file_path) if restore_dependencies_document.path in documents_to_add: - logger.debug(f'Duplicate document on restore for path: {restore_dependencies_document.path}') + logger.debug('Duplicate document on restore for path: %s', restore_dependencies_document.path) else: documents_to_add[restore_dependencies_document.path] = restore_dependencies_document @@ -147,5 +147,5 @@ def perform_pre_scan_documents_actions( context: click.Context, scan_type: str, documents_to_scan: List[Document], is_git_diff: bool = False ) -> None: if scan_type == consts.SCA_SCAN_TYPE and not context.obj.get(consts.SCA_SKIP_RESTORE_DEPENDENCIES_FLAG): - logger.debug('Perform pre scan document add_dependencies_tree_document action') + logger.debug('Perform pre-scan document add_dependencies_tree_document action') add_dependencies_tree_document(context, documents_to_scan, is_git_diff) diff --git a/cycode/cli/files_collector/zip_documents.py b/cycode/cli/files_collector/zip_documents.py index 97f42a30..7d57a47c 100644 --- a/cycode/cli/files_collector/zip_documents.py +++ b/cycode/cli/files_collector/zip_documents.py @@ -1,4 +1,4 @@ -import time +import timeit from pathlib import Path from typing import List, Optional @@ -22,25 +22,26 @@ def zip_documents(scan_type: str, documents: List[Document], zip_file: Optional[ if zip_file is None: zip_file = InMemoryZip() - start_zip_creation_time = time.time() + start_zip_creation_time = timeit.default_timer() for index, document in enumerate(documents): _validate_zip_file_size(scan_type, zip_file.size) logger.debug( - 'adding file to zip, %s', {'index': index, 'filename': document.path, 'unique_id': document.unique_id} + 'Adding file to ZIP, %s', + {'index': index, 'filename': document.path, 'unique_id': document.unique_id}, ) zip_file.append(document.path, document.unique_id, document.content) zip_file.close() - end_zip_creation_time = time.time() + end_zip_creation_time = timeit.default_timer() zip_creation_time = int(end_zip_creation_time - start_zip_creation_time) - logger.debug('finished to create zip file, %s', {'zip_creation_time': zip_creation_time}) + logger.debug('Finished to create ZIP file, %s', {'zip_creation_time': zip_creation_time}) if zip_file.configuration_manager.get_debug_flag(): zip_file_path = Path.joinpath(Path.cwd(), f'{scan_type}_scan_{end_zip_creation_time}.zip') - logger.debug('writing zip file to disk, %s', {'zip_file_path': zip_file_path}) + logger.debug('Writing ZIP file to disk, %s', {'zip_file_path': zip_file_path}) zip_file.write_on_disk(zip_file_path) return zip_file diff --git a/cycode/cli/utils/progress_bar.py b/cycode/cli/utils/progress_bar.py index b0e94d92..3bb6514d 100644 --- a/cycode/cli/utils/progress_bar.py +++ b/cycode/cli/utils/progress_bar.py @@ -11,8 +11,8 @@ from click._termui_impl import ProgressBar from click.termui import V as ProgressBarValue - -logger = get_logger('progress bar') +# use LOGGING_LEVEL=DEBUG env var to see debug logs of this module +logger = get_logger('progress bar', control_level_in_runtime=False) class ProgressBarSection(AutoCountEnum): @@ -184,7 +184,7 @@ def stop(self) -> None: self.__exit__(None, None, None) def set_section_length(self, section: 'ProgressBarSection', length: int = 0) -> None: - logger.debug(f'set_section_length: {section} {length}') + logger.debug('Calling set_section_length, %s', {'section': str(section), 'length': length}) self._section_lengths[section] = length if length == 0: @@ -203,8 +203,11 @@ def _skip_section(self, section: 'ProgressBarSection') -> None: def _increment_section_value(self, section: 'ProgressBarSection', value: int) -> None: self._section_values[section] = self._section_values.get(section, 0) + value logger.debug( - f'_increment_section_value: {section} +{value}. ' - f'{self._section_values[section]}/{self._section_lengths[section]}' + 'Calling _increment_section_value: %s +%s. %s/%s', + section, + value, + self._section_values[section], + self._section_lengths[section], ) def _rerender_progress_bar(self) -> None: @@ -225,7 +228,9 @@ def _maybe_update_current_section(self) -> None: cur_val = self._section_values.get(self._current_section.section, 0) if cur_val >= max_val: next_section = self._progress_bar_sections[self._current_section.section.next()] - logger.debug(f'_update_current_section: {self._current_section.section} -> {next_section.section}') + logger.debug( + 'Calling _update_current_section: %s -> %s', self._current_section.section, next_section.section + ) self._current_section = next_section self._current_section_value = 0 diff --git a/cycode/cli/utils/shell_executor.py b/cycode/cli/utils/shell_executor.py index a4ed889a..15cdba47 100644 --- a/cycode/cli/utils/shell_executor.py +++ b/cycode/cli/utils/shell_executor.py @@ -11,7 +11,7 @@ def shell( command: Union[str, List[str]], timeout: int = _SUBPROCESS_DEFAULT_TIMEOUT_SEC, execute_in_shell: bool = False ) -> Optional[str]: - logger.debug(f'Executing shell command: {command}') + logger.debug('Executing shell command: %s', command) try: result = subprocess.run( @@ -24,7 +24,7 @@ def shell( return result.stdout.decode('UTF-8').strip() except subprocess.CalledProcessError as e: - logger.debug(f'Error occurred while running shell command. Exception: {e.stderr}') + logger.debug('Error occurred while running shell command', exc_info=e) except subprocess.TimeoutExpired as e: raise click.Abort(f'Command "{command}" timed out') from e except Exception as e: diff --git a/cycode/cyclient/config.py b/cycode/cyclient/config.py index ade271d1..926723d1 100644 --- a/cycode/cyclient/config.py +++ b/cycode/cyclient/config.py @@ -1,7 +1,7 @@ import logging import os import sys -from typing import Optional, Union +from typing import NamedTuple, Optional, Set, Union from urllib.parse import urlparse from cycode.cli import consts @@ -42,7 +42,13 @@ def _set_io_encodings() -> None: configuration = dict(DEFAULT_CONFIGURATION, **os.environ) -_CREATED_LOGGERS = set() + +class CreatedLogger(NamedTuple): + logger: logging.Logger + control_level_in_runtime: bool + + +_CREATED_LOGGERS: Set[CreatedLogger] = set() def get_logger_level() -> Optional[Union[int, str]]: @@ -50,18 +56,19 @@ def get_logger_level() -> Optional[Union[int, str]]: return logging.getLevelName(config_level) -def get_logger(logger_name: Optional[str] = None) -> logging.Logger: +def get_logger(logger_name: Optional[str] = None, control_level_in_runtime: bool = True) -> logging.Logger: new_logger = logging.getLogger(logger_name) new_logger.setLevel(get_logger_level()) - _CREATED_LOGGERS.add(new_logger) + _CREATED_LOGGERS.add(CreatedLogger(logger=new_logger, control_level_in_runtime=control_level_in_runtime)) return new_logger def set_logging_level(level: int) -> None: for created_logger in _CREATED_LOGGERS: - created_logger.setLevel(level) + if created_logger.control_level_in_runtime: + created_logger.logger.setLevel(level) def get_val_as_string(key: str) -> str: @@ -83,10 +90,9 @@ def get_val_as_int(key: str) -> Optional[int]: def is_valid_url(url: str) -> bool: try: - urlparse(url) - return True - except ValueError as e: - logger.warning(f'Invalid cycode api url: {url}, using default value', e) + parsed_url = urlparse(url) + return all([parsed_url.scheme, parsed_url.netloc]) + except ValueError: return False @@ -95,6 +101,9 @@ def is_valid_url(url: str) -> bool: cycode_api_url = configuration_manager.get_cycode_api_url() if not is_valid_url(cycode_api_url): + logger.warning( + 'Invalid Cycode API URL: %s, using default value (%s)', cycode_api_url, consts.DEFAULT_CYCODE_API_URL + ) cycode_api_url = consts.DEFAULT_CYCODE_API_URL timeout = get_val_as_int(consts.CYCODE_CLI_REQUEST_TIMEOUT_ENV_VAR_NAME) diff --git a/cycode/cyclient/cycode_client_base.py b/cycode/cyclient/cycode_client_base.py index a4c5ab63..fbf3a91c 100644 --- a/cycode/cyclient/cycode_client_base.py +++ b/cycode/cyclient/cycode_client_base.py @@ -44,7 +44,10 @@ def _execute( **kwargs, ) -> Response: url = self.build_full_url(self.api_url, endpoint) - logger.debug(f'Executing {method.upper()} request to {url}') + logger.debug( + 'Executing request, %s', + {'method': method.upper(), 'url': url}, + ) timeout = self.timeout if 'timeout' in kwargs: @@ -56,7 +59,10 @@ def _execute( response = request(method=method, url=url, timeout=timeout, headers=headers, **kwargs) content = 'HIDDEN' if hide_response_content_log else response.text - logger.debug(f'Response {response.status_code} from {url}. Content: {content}') + logger.debug( + 'Receiving response, %s', + {'status_code': response.status_code, 'url': url, 'content': content}, + ) response.raise_for_status() return response diff --git a/cycode/cyclient/headers.py b/cycode/cyclient/headers.py index 53eba880..cc0444fb 100644 --- a/cycode/cyclient/headers.py +++ b/cycode/cyclient/headers.py @@ -38,7 +38,7 @@ def get_correlation_id(self) -> str: if self._id is None: # example: 16fd2706-8baf-433b-82eb-8c7fada847da self._id = str(uuid4()) - logger.debug(f'Correlation ID: {self._id}') + logger.debug('Correlation ID: %s', self._id) return self._id diff --git a/pyproject.toml b/pyproject.toml index 8e76e284..e4d7995e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,6 +96,7 @@ extend-select = [ "TCH", "TID", "YTT", + "G", ] line-length = 120 target-version = "py37" diff --git a/tests/test_performance_get_all_files.py b/tests/test_performance_get_all_files.py index b10b86e7..60155261 100644 --- a/tests/test_performance_get_all_files.py +++ b/tests/test_performance_get_all_files.py @@ -69,15 +69,15 @@ def test_get_all_files_performance(test_files_path: str) -> None: executed_time = timeit.default_timer() - start_time results[name] = (files_count, executed_time) - logger.info(f'Time result {name}: {executed_time}') - logger.info(f'Files count {name}: {files_count}') + logger.info('Time result %s: %s', name, executed_time) + logger.info('Files count %s: %s', name, files_count) files_counts = [result[0] for result in results.values()] assert len(set(files_counts)) == 1 # all should be equal - logger.info(f'Benchmark TOP with ({files_counts[0]}) files:') + logger.info('Benchmark TOP with (%s) files:', files_counts[0]) for func_name, result in sorted(results.items(), key=lambda x: x[1][1]): - logger.info(f'- {func_name}: {result[1]}') + logger.info('- %s: %s', func_name, result[1]) # according to my (MarshalX) local tests, the fastest is get_all_files_walk From 8b912797dd6048f6dc7348e65a301a5a5279590e Mon Sep 17 00:00:00 2001 From: saramontif Date: Tue, 28 May 2024 13:47:04 +0300 Subject: [PATCH 094/257] CM-34882 - Add one report URL for all secrets found in the same scan (#228) --- cycode/cli/commands/scan/code_scanner.py | 36 +++++++++++--- cycode/cli/printers/console_printer.py | 6 ++- cycode/cli/printers/json_printer.py | 6 ++- .../cli/printers/tables/sca_table_printer.py | 5 +- cycode/cli/printers/tables/table_printer.py | 2 +- .../cli/printers/tables/table_printer_base.py | 10 +++- cycode/cli/printers/text_printer.py | 32 ++++++++----- cycode/cyclient/scan_client.py | 14 +++++- .../cyclient/mocked_responses/scan_client.py | 14 ++++++ tests/test_code_scanner.py | 48 ++++++++++++++++++- 10 files changed, 141 insertions(+), 32 deletions(-) diff --git a/cycode/cli/commands/scan/code_scanner.py b/cycode/cli/commands/scan/code_scanner.py index 134fbbd4..603e831e 100644 --- a/cycode/cli/commands/scan/code_scanner.py +++ b/cycode/cli/commands/scan/code_scanner.py @@ -148,14 +148,14 @@ def _enrich_scan_result_with_data_from_detection_rules( def _get_scan_documents_thread_func( context: click.Context, is_git_diff: bool, is_commit_range: bool, scan_parameters: dict -) -> Callable[[List[Document]], Tuple[str, CliError, LocalScanResult]]: +) -> Tuple[Callable[[List[Document]], Tuple[str, CliError, LocalScanResult]], str]: cycode_client = context.obj['client'] scan_type = context.obj['scan_type'] severity_threshold = context.obj['severity_threshold'] sync_option = context.obj['sync'] command_scan_type = context.info_name - - scan_parameters['aggregation_id'] = str(_generate_unique_id()) + aggregation_id = str(_generate_unique_id()) + scan_parameters['aggregation_id'] = aggregation_id def _scan_batch_thread_func(batch: List[Document]) -> Tuple[str, CliError, LocalScanResult]: local_scan_result = error = error_message = None @@ -224,7 +224,7 @@ def _scan_batch_thread_func(batch: List[Document]) -> Tuple[str, CliError, Local return scan_id, error, local_scan_result - return _scan_batch_thread_func + return _scan_batch_thread_func, aggregation_id def scan_commit_range( @@ -313,11 +313,16 @@ def scan_documents( ) return - scan_batch_thread_func = _get_scan_documents_thread_func(context, is_git_diff, is_commit_range, scan_parameters) + scan_batch_thread_func, aggregation_id = _get_scan_documents_thread_func( + context, is_git_diff, is_commit_range, scan_parameters + ) errors, local_scan_results = run_parallel_batched_scan( scan_batch_thread_func, documents_to_scan, progress_bar=progress_bar ) - + aggregation_report_url = _try_get_aggregation_report_url_if_needed( + scan_parameters, context.obj['client'], context.obj['scan_type'] + ) + set_aggregation_report_url(context, aggregation_report_url) progress_bar.set_section_length(ScanProgressBarSection.GENERATE_REPORT, 1) progress_bar.update(ScanProgressBarSection.GENERATE_REPORT) progress_bar.stop() @@ -326,6 +331,25 @@ def scan_documents( print_results(context, local_scan_results, errors) +def set_aggregation_report_url(context: click.Context, aggregation_report_url: Optional[str] = None) -> None: + context.obj['aggregation_report_url'] = aggregation_report_url + + +def _try_get_aggregation_report_url_if_needed( + scan_parameters: dict, cycode_client: 'ScanClient', scan_type: str +) -> Optional[str]: + aggregation_id = scan_parameters.get('aggregation_id') + if not scan_parameters.get('report'): + return None + if aggregation_id is None: + return None + try: + report_url_response = cycode_client.get_scan_aggregation_report_url(aggregation_id, scan_type) + return report_url_response.report_url + except Exception as e: + logger.debug('Failed to get aggregation report url: %s', str(e)) + + def scan_commit_range_documents( context: click.Context, from_documents_to_scan: List[Document], diff --git a/cycode/cli/printers/console_printer.py b/cycode/cli/printers/console_printer.py index 667cdd6a..ad473560 100644 --- a/cycode/cli/printers/console_printer.py +++ b/cycode/cli/printers/console_printer.py @@ -28,13 +28,15 @@ def __init__(self, context: click.Context) -> None: self.context = context self.scan_type = self.context.obj.get('scan_type') self.output_type = self.context.obj.get('output') - + self.aggregation_report_url = self.context.obj.get('aggregation_report_url') self._printer_class = self._AVAILABLE_PRINTERS.get(self.output_type) if self._printer_class is None: raise CycodeError(f'"{self.output_type}" output type is not supported.') def print_scan_results( - self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None + self, + local_scan_results: List['LocalScanResult'], + errors: Optional[Dict[str, 'CliError']] = None, ) -> None: printer = self._get_scan_printer() printer.print_scan_results(local_scan_results, errors) diff --git a/cycode/cli/printers/json_printer.py b/cycode/cli/printers/json_printer.py index 44ec9c85..187a1bf8 100644 --- a/cycode/cli/printers/json_printer.py +++ b/cycode/cli/printers/json_printer.py @@ -28,13 +28,15 @@ def print_scan_results( scan_ids = [] report_urls = [] detections = [] + aggregation_report_url = self.context.obj.get('aggregation_report_url') + if aggregation_report_url: + report_urls.append(aggregation_report_url) for local_scan_result in local_scan_results: scan_ids.append(local_scan_result.scan_id) - if local_scan_result.report_url: + if not aggregation_report_url and local_scan_result.report_url: report_urls.append(local_scan_result.report_url) - for document_detections in local_scan_result.document_detections: detections.extend(document_detections.detections) diff --git a/cycode/cli/printers/tables/sca_table_printer.py b/cycode/cli/printers/tables/sca_table_printer.py index 268f8614..d51359a3 100644 --- a/cycode/cli/printers/tables/sca_table_printer.py +++ b/cycode/cli/printers/tables/sca_table_printer.py @@ -13,7 +13,6 @@ if TYPE_CHECKING: from cycode.cli.models import LocalScanResult - column_builder = ColumnInfoBuilder() # Building must have strict order. Represents the order of the columns in the table (from left to right) @@ -29,7 +28,6 @@ DIRECT_DEPENDENCY_COLUMN = column_builder.build(name='Direct Dependency') DEVELOPMENT_DEPENDENCY_COLUMN = column_builder.build(name='Development Dependency') - COLUMN_WIDTHS_CONFIG: ColumnWidths = { REPOSITORY_COLUMN: 2, CODE_PROJECT_COLUMN: 2, @@ -42,6 +40,7 @@ class ScaTablePrinter(TablePrinterBase): def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: + aggregation_report_url = self.context.obj.get('aggregation_report_url') detections_per_policy_id = self._extract_detections_per_policy_id(local_scan_results) for policy_id, detections in detections_per_policy_id.items(): table = self._get_table(policy_id) @@ -53,7 +52,7 @@ def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: self._print_summary_issues(len(detections), self._get_title(policy_id)) self._print_table(table) - self._print_report_urls(local_scan_results) + self._print_report_urls(local_scan_results, aggregation_report_url) @staticmethod def _get_title(policy_id: str) -> str: diff --git a/cycode/cli/printers/tables/table_printer.py b/cycode/cli/printers/tables/table_printer.py index 6afd9e66..f2153e56 100644 --- a/cycode/cli/printers/tables/table_printer.py +++ b/cycode/cli/printers/tables/table_printer.py @@ -63,7 +63,7 @@ def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: self._enrich_table_with_values(table, detection, document_detections.document) self._print_table(table) - self._print_report_urls(local_scan_results) + self._print_report_urls(local_scan_results, self.context.obj.get('aggregation_report_url')) def _get_table(self) -> Table: table = Table() diff --git a/cycode/cli/printers/tables/table_printer_base.py b/cycode/cli/printers/tables/table_printer_base.py index 9b6e8ac7..be41454f 100644 --- a/cycode/cli/printers/tables/table_printer_base.py +++ b/cycode/cli/printers/tables/table_printer_base.py @@ -58,9 +58,15 @@ def _print_table(table: 'Table') -> None: click.echo(table.get_table().draw()) @staticmethod - def _print_report_urls(local_scan_results: List['LocalScanResult']) -> None: + def _print_report_urls( + local_scan_results: List['LocalScanResult'], + aggregation_report_url: Optional[str] = None, + ) -> None: report_urls = [scan_result.report_url for scan_result in local_scan_results if scan_result.report_url] - if not report_urls: + if not report_urls and not aggregation_report_url: + return + if aggregation_report_url: + click.echo(f'Report URL: {aggregation_report_url}') return click.echo('Report URLs:') diff --git a/cycode/cli/printers/text_printer.py b/cycode/cli/printers/text_printer.py index 2bbab6a3..1e2babd2 100644 --- a/cycode/cli/printers/text_printer.py +++ b/cycode/cli/printers/text_printer.py @@ -39,10 +39,11 @@ def print_scan_results( for local_scan_result in local_scan_results: for document_detections in local_scan_result.document_detections: - self._print_document_detections( - document_detections, local_scan_result.scan_id, local_scan_result.report_url - ) + self._print_document_detections(document_detections, local_scan_result.scan_id) + report_urls = [scan_result.report_url for scan_result in local_scan_results if scan_result.report_url] + + self._print_report_urls(report_urls, self.context.obj.get('aggregation_report_url')) if not errors: return @@ -55,18 +56,14 @@ def print_scan_results( click.echo(f'- {scan_id}: ', nl=False) self.print_error(error) - def _print_document_detections( - self, document_detections: DocumentDetections, scan_id: str, report_url: Optional[str] - ) -> None: + def _print_document_detections(self, document_detections: DocumentDetections, scan_id: str) -> None: document = document_detections.document lines_to_display = self._get_lines_to_display_count() for detection in document_detections.detections: - self._print_detection_summary(detection, document.path, scan_id, report_url) + self._print_detection_summary(detection, document.path, scan_id) self._print_detection_code_segment(detection, document, lines_to_display) - def _print_detection_summary( - self, detection: Detection, document_path: str, scan_id: str, report_url: Optional[str] - ) -> None: + def _print_detection_summary(self, detection: Detection, document_path: str, scan_id: str) -> None: detection_name = detection.type if self.scan_type == SECRET_SCAN_TYPE else detection.message detection_name_styled = click.style(detection_name, fg='bright_red', bold=True) @@ -74,8 +71,6 @@ def _print_detection_summary( detection_sha_message = f'\nSecret SHA: {detection_sha}' if detection_sha else '' scan_id_message = f'\nScan ID: {scan_id}' - report_url_message = f'\nReport URL: {report_url}' if report_url else '' - detection_commit_id = detection.detection_details.get('commit_id') detection_commit_id_message = f'\nCommit SHA: {detection_commit_id}' if detection_commit_id else '' @@ -88,7 +83,6 @@ def _print_detection_summary( f'(rule ID: {detection.detection_rule_id}) in file: {click.format_filename(document_path)} ' f'{detection_sha_message}' f'{scan_id_message}' - f'{report_url_message}' f'{detection_commit_id_message}' f'{company_guidelines_message}' f' ⛔' @@ -101,6 +95,18 @@ def _print_detection_code_segment(self, detection: Detection, document: Document self._print_detection_from_file(detection, document, code_segment_size) + @staticmethod + def _print_report_urls(report_urls: List[str], aggregation_report_url: Optional[str] = None) -> None: + if not report_urls and not aggregation_report_url: + return + if aggregation_report_url: + click.echo(f'Report URL: {aggregation_report_url}') + return + + click.echo('Report URLs:') + for report_url in report_urls: + click.echo(f'- {report_url}') + @staticmethod def _get_code_segment_start_line(detection_line: int, code_segment_size: int) -> int: start_line = detection_line - math.ceil(code_segment_size / 2) diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index e3e2c85b..9431c9a3 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -59,7 +59,7 @@ def get_scan_service_url_path( self, scan_type: str, should_use_scan_service: bool = False, should_use_sync_flow: bool = False ) -> str: service_path = self.scan_config.get_service_name(scan_type, should_use_scan_service) - controller_path = self.get_scan_controller_path(scan_type) + controller_path = self.get_scan_controller_path(scan_type, should_use_scan_service) flow_type = self.get_scan_flow_type(should_use_sync_flow) return f'{service_path}/{controller_path}{flow_type}' @@ -92,6 +92,12 @@ def get_scan_report_url(self, scan_id: str, scan_type: str) -> models.ScanReport response = self.scan_cycode_client.get(url_path=self.get_scan_report_url_path(scan_id, scan_type)) return models.ScanReportUrlResponseSchema().build_dto(response.json()) + def get_scan_aggregation_report_url(self, aggregation_id: str, scan_type: str) -> models.ScanReportUrlResponse: + response = self.scan_cycode_client.get( + url_path=self.get_scan_aggregation_report_url_path(aggregation_id, scan_type) + ) + return models.ScanReportUrlResponseSchema().build_dto(response.json()) + def get_zipped_file_scan_async_url_path(self, scan_type: str, should_use_sync_flow: bool = False) -> str: async_scan_type = self.scan_config.get_async_scan_type(scan_type) async_entity_type = self.scan_config.get_async_entity_type(scan_type) @@ -155,6 +161,12 @@ def get_scan_details_path(self, scan_type: str, scan_id: str) -> str: def get_scan_report_url_path(self, scan_id: str, scan_type: str) -> str: return f'{self.get_scan_service_url_path(scan_type, should_use_scan_service=True)}/reportUrl/{scan_id}' + def get_scan_aggregation_report_url_path(self, aggregation_id: str, scan_type: str) -> str: + return ( + f'{self.get_scan_service_url_path(scan_type, should_use_scan_service=True)}' + f'/reportUrlByAggregationId/{aggregation_id}' + ) + def get_scan_details(self, scan_type: str, scan_id: str) -> models.ScanDetailsResponse: path = self.get_scan_details_path(scan_type, scan_id) response = self.scan_cycode_client.get(url_path=path) diff --git a/tests/cyclient/mocked_responses/scan_client.py b/tests/cyclient/mocked_responses/scan_client.py index a3117f55..f6b20194 100644 --- a/tests/cyclient/mocked_responses/scan_client.py +++ b/tests/cyclient/mocked_responses/scan_client.py @@ -85,6 +85,12 @@ def get_scan_report_url(scan_id: Optional[UUID], scan_client: ScanClient, scan_t return f'{api_url}/{service_url}' +def get_scan_aggregation_report_url(aggregation_id: Optional[UUID], scan_client: ScanClient, scan_type: str) -> str: + api_url = scan_client.scan_cycode_client.api_url + service_url = scan_client.get_scan_aggregation_report_url_path(str(aggregation_id), scan_type) + return f'{api_url}/{service_url}' + + def get_scan_report_url_response(url: str, scan_id: Optional[UUID] = None) -> responses.Response: if not scan_id: scan_id = uuid4() @@ -93,6 +99,14 @@ def get_scan_report_url_response(url: str, scan_id: Optional[UUID] = None) -> re return responses.Response(method=responses.GET, url=url, json=json_response, status=200) +def get_scan_aggregation_report_url_response(url: str, aggregation_id: Optional[UUID] = None) -> responses.Response: + if not aggregation_id: + aggregation_id = uuid4() + json_response = {'report_url': f'https://app.domain/cli-logs-aggregation/{aggregation_id}'} + + return responses.Response(method=responses.GET, url=url, json=json_response, status=200) + + def get_scan_details_response(url: str, scan_id: Optional[UUID] = None) -> responses.Response: if not scan_id: scan_id = uuid4() diff --git a/tests/test_code_scanner.py b/tests/test_code_scanner.py index f18e5c02..d789312d 100644 --- a/tests/test_code_scanner.py +++ b/tests/test_code_scanner.py @@ -4,12 +4,20 @@ import pytest import responses -from cycode.cli.commands.scan.code_scanner import _try_get_report_url_if_needed +from cycode.cli.commands.scan.code_scanner import ( + _try_get_aggregation_report_url_if_needed, + _try_get_report_url_if_needed, +) from cycode.cli.config import config from cycode.cli.files_collector.excluder import _is_relevant_file_to_scan from cycode.cyclient.scan_client import ScanClient from tests.conftest import TEST_FILES_PATH -from tests.cyclient.mocked_responses.scan_client import get_scan_report_url, get_scan_report_url_response +from tests.cyclient.mocked_responses.scan_client import ( + get_scan_aggregation_report_url, + get_scan_aggregation_report_url_response, + get_scan_report_url, + get_scan_report_url_response, +) def test_is_relevant_file_to_scan_sca() -> None: @@ -37,3 +45,39 @@ def test_try_get_report_url_if_needed_return_result( scan_report_url_response = scan_client.get_scan_report_url(str(scan_id), scan_type) result = _try_get_report_url_if_needed(scan_client, True, str(scan_id), scan_type) assert result == scan_report_url_response.report_url + + +@pytest.mark.parametrize('scan_type', config['scans']['supported_scans']) +def test_try_get_aggregation_report_url_if_no_report_command_needed_return_none( + scan_type: str, scan_client: ScanClient +) -> None: + aggregation_id = uuid4().hex + scan_parameter = {'aggregation_id': aggregation_id} + result = _try_get_aggregation_report_url_if_needed(scan_parameter, scan_client, scan_type) + assert result is None + + +@pytest.mark.parametrize('scan_type', config['scans']['supported_scans']) +def test_try_get_aggregation_report_url_if_no_aggregation_id_needed_return_none( + scan_type: str, scan_client: ScanClient +) -> None: + scan_parameter = {'report': True} + result = _try_get_aggregation_report_url_if_needed(scan_parameter, scan_client, scan_type) + assert result is None + + +@pytest.mark.parametrize('scan_type', config['scans']['supported_scans']) +@responses.activate +def test_try_get_aggregation_report_url_if_needed_return_result( + scan_type: str, scan_client: ScanClient, api_token_response: responses.Response +) -> None: + aggregation_id = uuid4() + scan_parameter = {'report': True, 'aggregation_id': aggregation_id} + url = get_scan_aggregation_report_url(aggregation_id, scan_client, scan_type) + responses.add(api_token_response) # mock token based client + responses.add(get_scan_aggregation_report_url_response(url, aggregation_id)) + + scan_aggregation_report_url_response = scan_client.get_scan_aggregation_report_url(str(aggregation_id), scan_type) + + result = _try_get_aggregation_report_url_if_needed(scan_parameter, scan_client, scan_type) + assert result == scan_aggregation_report_url_response.report_url From e63c1dbcc9bb3eb58f180535b5ec50378e1917ee Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 28 May 2024 13:10:26 +0200 Subject: [PATCH 095/257] CM-30526 - Make Git executable optional (#230) --- cycode/cli/commands/scan/code_scanner.py | 12 ++- .../scan/pre_commit/pre_commit_command.py | 4 +- cycode/cli/exceptions/handle_scan_errors.py | 13 ++-- .../files_collector/repository_documents.py | 14 ++-- .../files_collector/sca/sca_code_scanner.py | 16 ++-- cycode/cli/printers/printer_base.py | 2 +- cycode/cli/utils/git_proxy.py | 76 +++++++++++++++++++ .../cli/exceptions/test_handle_scan_errors.py | 14 ++-- tests/utils/test_git_proxy.py | 53 +++++++++++++ 9 files changed, 169 insertions(+), 35 deletions(-) create mode 100644 cycode/cli/utils/git_proxy.py create mode 100644 tests/utils/test_git_proxy.py diff --git a/cycode/cli/commands/scan/code_scanner.py b/cycode/cli/commands/scan/code_scanner.py index 603e831e..701a6a86 100644 --- a/cycode/cli/commands/scan/code_scanner.py +++ b/cycode/cli/commands/scan/code_scanner.py @@ -7,7 +7,6 @@ from uuid import UUID, uuid4 import click -from git import NULL_TREE, Repo from cycode.cli import consts from cycode.cli.config import configuration_manager @@ -28,9 +27,8 @@ from cycode.cli.models import CliError, Document, DocumentDetections, LocalScanResult, Severity from cycode.cli.printers import ConsolePrinter from cycode.cli.utils import scan_utils -from cycode.cli.utils.path_utils import ( - get_path_by_os, -) +from cycode.cli.utils.git_proxy import git_proxy +from cycode.cli.utils.path_utils import get_path_by_os from cycode.cli.utils.progress_bar import ScanProgressBarSection from cycode.cli.utils.scan_batch import run_parallel_batched_scan from cycode.cli.utils.scan_utils import set_issue_detected @@ -244,7 +242,7 @@ def scan_commit_range( documents_to_scan = [] commit_ids_to_scan = [] - repo = Repo(path) + repo = git_proxy.get_repo(path) total_commits_count = int(repo.git.rev_list('--count', commit_range)) logger.debug('Calculating diffs for %s commits in the commit range %s', total_commits_count, commit_range) @@ -261,7 +259,7 @@ def scan_commit_range( commit_id = commit.hexsha commit_ids_to_scan.append(commit_id) - parent = commit.parents[0] if commit.parents else NULL_TREE + parent = commit.parents[0] if commit.parents else git_proxy.get_null_tree() diff = commit.diff(parent, create_patch=True, R=True) commit_documents_to_scan = [] for blob in diff: @@ -688,7 +686,7 @@ def get_scan_parameters(context: click.Context, paths: Tuple[str]) -> dict: def try_get_git_remote_url(path: str) -> Optional[str]: try: - remote_url = Repo(path).remotes[0].config_reader.get('url') + remote_url = git_proxy.get_repo(path).remotes[0].config_reader.get('url') logger.debug('Found Git remote URL, %s', {'remote_url': remote_url, 'path': path}) return remote_url except Exception as e: 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 a758f0f5..657c839e 100644 --- a/cycode/cli/commands/scan/pre_commit/pre_commit_command.py +++ b/cycode/cli/commands/scan/pre_commit/pre_commit_command.py @@ -2,7 +2,6 @@ from typing import List import click -from git import Repo from cycode.cli import consts from cycode.cli.commands.scan.code_scanner import scan_documents, scan_sca_pre_commit @@ -12,6 +11,7 @@ get_diff_file_path, ) from cycode.cli.models import Document +from cycode.cli.utils.git_proxy import git_proxy from cycode.cli.utils.path_utils import ( get_path_by_os, ) @@ -31,7 +31,7 @@ def pre_commit_command(context: click.Context, ignored_args: List[str]) -> None: scan_sca_pre_commit(context) return - diff_files = Repo(os.getcwd()).index.diff('HEAD', create_patch=True, R=True) + diff_files = git_proxy.get_repo(os.getcwd()).index.diff('HEAD', create_patch=True, R=True) progress_bar.set_section_length(ScanProgressBarSection.PREPARE_LOCAL_FILES, len(diff_files)) diff --git a/cycode/cli/exceptions/handle_scan_errors.py b/cycode/cli/exceptions/handle_scan_errors.py index 39822da2..c59ffe8a 100644 --- a/cycode/cli/exceptions/handle_scan_errors.py +++ b/cycode/cli/exceptions/handle_scan_errors.py @@ -1,11 +1,11 @@ from typing import Optional import click -from git import InvalidGitRepositoryError from cycode.cli.exceptions import custom_exceptions from cycode.cli.models import CliError, CliErrors from cycode.cli.printers import ConsolePrinter +from cycode.cli.utils.git_proxy import git_proxy def handle_scan_exception( @@ -13,7 +13,7 @@ def handle_scan_exception( ) -> Optional[CliError]: context.obj['did_fail'] = True - ConsolePrinter(context).print_exception() + ConsolePrinter(context).print_exception(e) errors: CliErrors = { custom_exceptions.NetworkError: CliError( @@ -49,7 +49,7 @@ def handle_scan_exception( 'Please make sure that your file is well formed ' 'and execute the scan again', ), - InvalidGitRepositoryError: CliError( + git_proxy.get_invalid_git_repository_error(): CliError( soft_fail=False, code='invalid_git_error', message='The path you supplied does not correlate to a git repository. ' @@ -69,10 +69,13 @@ def handle_scan_exception( ConsolePrinter(context).print_error(error) return None + unknown_error = CliError(code='unknown_error', message=str(e)) + if return_exception: - return CliError(code='unknown_error', message=str(e)) + return unknown_error if isinstance(e, click.ClickException): raise e - raise click.ClickException(str(e)) + ConsolePrinter(context).print_error(unknown_error) + exit(1) diff --git a/cycode/cli/files_collector/repository_documents.py b/cycode/cli/files_collector/repository_documents.py index acd9c225..df49aa95 100644 --- a/cycode/cli/files_collector/repository_documents.py +++ b/cycode/cli/files_collector/repository_documents.py @@ -4,6 +4,7 @@ from cycode.cli import consts from cycode.cli.files_collector.sca import sca_code_scanner from cycode.cli.models import Document +from cycode.cli.utils.git_proxy import git_proxy from cycode.cli.utils.path_utils import get_file_content, get_path_by_os if TYPE_CHECKING: @@ -13,8 +14,6 @@ from cycode.cli.utils.progress_bar import BaseProgressBar, ProgressBarSection -from git import Repo - def should_process_git_object(obj: 'Blob', _: int) -> bool: return obj.type == 'blob' and obj.size > 0 @@ -23,14 +22,14 @@ def should_process_git_object(obj: 'Blob', _: int) -> bool: def get_git_repository_tree_file_entries( path: str, branch: str ) -> Union[Iterator['IndexObjUnion'], Iterator['TraversedTreeTup']]: - return Repo(path).tree(branch).traverse(predicate=should_process_git_object) + return git_proxy.get_repo(path).tree(branch).traverse(predicate=should_process_git_object) def parse_commit_range(commit_range: str, path: str) -> Tuple[str, str]: from_commit_rev = None to_commit_rev = None - for commit in Repo(path).iter_commits(rev=commit_range): + for commit in git_proxy.get_repo(path).iter_commits(rev=commit_range): if not to_commit_rev: to_commit_rev = commit.hexsha from_commit_rev = commit.hexsha @@ -52,7 +51,7 @@ def get_pre_commit_modified_documents( git_head_documents = [] pre_committed_documents = [] - repo = Repo(os.getcwd()) + repo = git_proxy.get_repo(os.getcwd()) diff_files = repo.index.diff(consts.GIT_HEAD_COMMIT_REV, create_patch=True, R=True) progress_bar.set_section_length(progress_bar_section, len(diff_files)) for file in diff_files: @@ -82,7 +81,7 @@ def get_commit_range_modified_documents( from_commit_documents = [] to_commit_documents = [] - repo = Repo(path) + repo = git_proxy.get_repo(path) diff = repo.commit(from_commit_rev).diff(to_commit_rev) modified_files_diff = [ @@ -131,7 +130,8 @@ def _get_end_commit_from_branch_update_details(update_details: str) -> str: def _get_oldest_unupdated_commit_for_branch(commit: str) -> Optional[str]: # get a list of commits by chronological order that are not in the remote repository yet # more info about rev-list command: https://git-scm.com/docs/git-rev-list - not_updated_commits = Repo(os.getcwd()).git.rev_list(commit, '--topo-order', '--reverse', '--not', '--all') + repo = git_proxy.get_repo(os.getcwd()) + not_updated_commits = repo.git.rev_list(commit, '--topo-order', '--reverse', '--not', '--all') commits = not_updated_commits.splitlines() if not commits: diff --git a/cycode/cli/files_collector/sca/sca_code_scanner.py b/cycode/cli/files_collector/sca/sca_code_scanner.py index 4c2139e7..0b94e941 100644 --- a/cycode/cli/files_collector/sca/sca_code_scanner.py +++ b/cycode/cli/files_collector/sca/sca_code_scanner.py @@ -2,16 +2,18 @@ from typing import TYPE_CHECKING, Dict, List, Optional import click -from git import GitCommandError, Repo from cycode.cli import consts from cycode.cli.files_collector.sca.maven.restore_gradle_dependencies import RestoreGradleDependencies from cycode.cli.files_collector.sca.maven.restore_maven_dependencies import RestoreMavenDependencies from cycode.cli.models import Document +from cycode.cli.utils.git_proxy import git_proxy from cycode.cli.utils.path_utils import get_file_content, get_file_dir, join_paths from cycode.cyclient import logger if TYPE_CHECKING: + from git import Repo + from cycode.cli.files_collector.sca.maven.base_restore_maven_dependencies import BaseRestoreMavenDependencies BUILD_GRADLE_FILE_NAME = 'build.gradle' @@ -27,7 +29,7 @@ def perform_pre_commit_range_scan_actions( to_commit_documents: List[Document], to_commit_rev: str, ) -> None: - repo = Repo(path) + repo = git_proxy.get_repo(path) add_ecosystem_related_files_if_exists(from_commit_documents, repo, from_commit_rev) add_ecosystem_related_files_if_exists(to_commit_documents, repo, to_commit_rev) @@ -35,13 +37,13 @@ def perform_pre_commit_range_scan_actions( def perform_pre_hook_range_scan_actions( git_head_documents: List[Document], pre_committed_documents: List[Document] ) -> None: - repo = Repo(os.getcwd()) + repo = git_proxy.get_repo(os.getcwd()) add_ecosystem_related_files_if_exists(git_head_documents, repo, consts.GIT_HEAD_COMMIT_REV) add_ecosystem_related_files_if_exists(pre_committed_documents) def add_ecosystem_related_files_if_exists( - documents: List[Document], repo: Optional[Repo] = None, commit_rev: Optional[str] = None + documents: List[Document], repo: Optional['Repo'] = None, commit_rev: Optional[str] = None ) -> None: documents_to_add: List[Document] = [] for doc in documents: @@ -56,7 +58,7 @@ def add_ecosystem_related_files_if_exists( def get_doc_ecosystem_related_project_files( - doc: Document, documents: List[Document], ecosystem: str, commit_rev: Optional[str], repo: Optional[Repo] + doc: Document, documents: List[Document], ecosystem: str, commit_rev: Optional[str], repo: Optional['Repo'] ) -> List[Document]: documents_to_add: List[Document] = [] for ecosystem_project_file in consts.PROJECT_FILES_BY_ECOSYSTEM_MAP.get(ecosystem): @@ -136,10 +138,10 @@ def get_manifest_file_path(document: Document, is_monitor_action: bool, project_ return join_paths(project_path, document.path) if is_monitor_action else document.path -def get_file_content_from_commit(repo: Repo, commit: str, file_path: str) -> Optional[str]: +def get_file_content_from_commit(repo: 'Repo', commit: str, file_path: str) -> Optional[str]: try: return repo.git.show(f'{commit}:{file_path}') - except GitCommandError: + except git_proxy.get_git_command_error(): return None diff --git a/cycode/cli/printers/printer_base.py b/cycode/cli/printers/printer_base.py index cc354082..fa5bf435 100644 --- a/cycode/cli/printers/printer_base.py +++ b/cycode/cli/printers/printer_base.py @@ -43,7 +43,7 @@ def print_exception(self, e: Optional[BaseException] = None) -> None: # gets the most recent exception caught by an except clause message = f'Error: {traceback.format_exc()}' else: - traceback_message = ''.join(traceback.format_exception(e)) + traceback_message = ''.join(traceback.format_exception(None, e, e.__traceback__)) message = f'Error: {traceback_message}' click.secho(message, err=True, fg=self.RED_COLOR_NAME) diff --git a/cycode/cli/utils/git_proxy.py b/cycode/cli/utils/git_proxy.py new file mode 100644 index 00000000..4143c657 --- /dev/null +++ b/cycode/cli/utils/git_proxy.py @@ -0,0 +1,76 @@ +import types +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Optional, Type + +_GIT_ERROR_MESSAGE = """ +Cycode CLI needs the git executable to be installed on the system. +Git executable must be available in the PATH. +Git 1.7.x or newer is required. +You can help Cycode CLI to locate the Git executable +by setting the GIT_PYTHON_GIT_EXECUTABLE= environment variable. +""".strip().replace('\n', ' ') + +try: + import git +except ImportError: + git = None + +if TYPE_CHECKING: + from git import PathLike, Repo + + +class GitProxyError(Exception): + pass + + +class _AbstractGitProxy(ABC): + @abstractmethod + def get_repo(self, path: Optional['PathLike'] = None, *args, **kwargs) -> 'Repo': + ... + + @abstractmethod + def get_null_tree(self) -> object: + ... + + @abstractmethod + def get_invalid_git_repository_error(self) -> Type[BaseException]: + ... + + @abstractmethod + def get_git_command_error(self) -> Type[BaseException]: + ... + + +class _DummyGitProxy(_AbstractGitProxy): + def get_repo(self, path: Optional['PathLike'] = None, *args, **kwargs) -> 'Repo': + raise RuntimeError(_GIT_ERROR_MESSAGE) + + def get_null_tree(self) -> object: + raise RuntimeError(_GIT_ERROR_MESSAGE) + + def get_invalid_git_repository_error(self) -> Type[BaseException]: + return GitProxyError + + def get_git_command_error(self) -> Type[BaseException]: + return GitProxyError + + +class _GitProxy(_AbstractGitProxy): + def get_repo(self, path: Optional['PathLike'] = None, *args, **kwargs) -> 'Repo': + return git.Repo(path, *args, **kwargs) + + def get_null_tree(self) -> object: + return git.NULL_TREE + + def get_invalid_git_repository_error(self) -> Type[BaseException]: + return git.InvalidGitRepositoryError + + def get_git_command_error(self) -> Type[BaseException]: + return git.GitCommandError + + +def get_git_proxy(git_module: Optional[types.ModuleType]) -> _AbstractGitProxy: + return _GitProxy() if git_module else _DummyGitProxy() + + +git_proxy = get_git_proxy(git) diff --git a/tests/cli/exceptions/test_handle_scan_errors.py b/tests/cli/exceptions/test_handle_scan_errors.py index d473801f..7130bb1b 100644 --- a/tests/cli/exceptions/test_handle_scan_errors.py +++ b/tests/cli/exceptions/test_handle_scan_errors.py @@ -3,11 +3,11 @@ import click import pytest from click import ClickException -from git import InvalidGitRepositoryError from requests import Response from cycode.cli.exceptions import custom_exceptions from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception +from cycode.cli.utils.git_proxy import git_proxy if TYPE_CHECKING: from _pytest.monkeypatch import MonkeyPatch @@ -26,7 +26,7 @@ def ctx() -> click.Context: (custom_exceptions.HttpUnauthorizedError('msg', Response()), True), (custom_exceptions.ZipTooLargeError(1000), True), (custom_exceptions.TfplanKeyError('msg'), True), - (InvalidGitRepositoryError(), None), + (git_proxy.get_invalid_git_repository_error()(), None), ], ) def test_handle_exception_soft_fail( @@ -40,7 +40,7 @@ def test_handle_exception_soft_fail( def test_handle_exception_unhandled_error(ctx: click.Context) -> None: - with ctx, pytest.raises(ClickException): + with ctx, pytest.raises(SystemExit): handle_scan_exception(ctx, ValueError('test')) assert ctx.obj.get('did_fail') is True @@ -58,10 +58,12 @@ def test_handle_exception_click_error(ctx: click.Context) -> None: def test_handle_exception_verbose(monkeypatch: 'MonkeyPatch') -> None: ctx = click.Context(click.Command('path'), obj={'verbose': True, 'output': 'text'}) + error_text = 'test' + def mock_secho(msg: str, *_, **__) -> None: - assert 'Error:' in msg or 'Correlation ID:' in msg + assert error_text in msg or 'Correlation ID:' in msg monkeypatch.setattr(click, 'secho', mock_secho) - with ctx, pytest.raises(ClickException): - handle_scan_exception(ctx, ValueError('test')) + with pytest.raises(SystemExit): + handle_scan_exception(ctx, ValueError(error_text)) diff --git a/tests/utils/test_git_proxy.py b/tests/utils/test_git_proxy.py new file mode 100644 index 00000000..62416361 --- /dev/null +++ b/tests/utils/test_git_proxy.py @@ -0,0 +1,53 @@ +import os +import tempfile + +import git as real_git +import pytest + +from cycode.cli.utils.git_proxy import _GIT_ERROR_MESSAGE, GitProxyError, _DummyGitProxy, _GitProxy, get_git_proxy + + +def test_get_git_proxy() -> None: + proxy = get_git_proxy(git_module=None) + assert isinstance(proxy, _DummyGitProxy) + + proxy2 = get_git_proxy(git_module=real_git) + assert isinstance(proxy2, _GitProxy) + + +def test_dummy_git_proxy() -> None: + proxy = _DummyGitProxy() + + with pytest.raises(RuntimeError) as exc: + proxy.get_repo() + assert str(exc.value) == _GIT_ERROR_MESSAGE + + with pytest.raises(RuntimeError) as exc2: + proxy.get_null_tree() + assert str(exc2.value) == _GIT_ERROR_MESSAGE + + assert proxy.get_git_command_error() is GitProxyError + assert proxy.get_invalid_git_repository_error() is GitProxyError + + +def test_git_proxy() -> None: + proxy = _GitProxy() + + repo = proxy.get_repo(os.getcwd(), search_parent_directories=True) + assert isinstance(repo, real_git.Repo) + + assert proxy.get_null_tree() is real_git.NULL_TREE + + assert proxy.get_git_command_error() is real_git.GitCommandError + assert proxy.get_invalid_git_repository_error() is real_git.InvalidGitRepositoryError + + with tempfile.TemporaryDirectory() as tmpdir: + with pytest.raises(real_git.InvalidGitRepositoryError): + proxy.get_repo(tmpdir) + with pytest.raises(proxy.get_invalid_git_repository_error()): + proxy.get_repo(tmpdir) + + with pytest.raises(real_git.GitCommandError): + repo.git.show('blabla') + with pytest.raises(proxy.get_git_command_error()): + repo.git.show('blabla') From 0533c7d00caca3f5eecef0976e4e8ce4fb35cf07 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 18 Jun 2024 14:19:45 +0200 Subject: [PATCH 096/257] CM-37160 - Remove legacy flow via detection count (#232) --- cycode/cli/commands/scan/code_scanner.py | 43 ++++--------------- cycode/cli/consts.py | 2 - cycode/cyclient/scan_client.py | 9 ---- .../cyclient/mocked_responses/scan_client.py | 13 ------ 4 files changed, 8 insertions(+), 59 deletions(-) diff --git a/cycode/cli/commands/scan/code_scanner.py b/cycode/cli/commands/scan/code_scanner.py index 701a6a86..67045fba 100644 --- a/cycode/cli/commands/scan/code_scanner.py +++ b/cycode/cli/commands/scan/code_scanner.py @@ -317,10 +317,14 @@ def scan_documents( errors, local_scan_results = run_parallel_batched_scan( scan_batch_thread_func, documents_to_scan, progress_bar=progress_bar ) - aggregation_report_url = _try_get_aggregation_report_url_if_needed( - scan_parameters, context.obj['client'], context.obj['scan_type'] - ) - set_aggregation_report_url(context, aggregation_report_url) + + if len(local_scan_results) > 1: + # if we used more than one batch, we need to fetch aggregate report url + aggregation_report_url = _try_get_aggregation_report_url_if_needed( + scan_parameters, context.obj['client'], context.obj['scan_type'] + ) + set_aggregation_report_url(context, aggregation_report_url) + progress_bar.set_section_length(ScanProgressBarSection.GENERATE_REPORT, 1) progress_bar.update(ScanProgressBarSection.GENERATE_REPORT) progress_bar.stop() @@ -863,8 +867,6 @@ def _get_scan_result( if not scan_details.detections_count: return init_default_scan_result(cycode_client, scan_id, scan_type, should_get_report) - wait_for_detections_creation(cycode_client, scan_type, scan_id, scan_details.detections_count) - scan_detections = cycode_client.get_scan_detections(scan_type, scan_id) return ZippedFileScanResult( @@ -899,35 +901,6 @@ def _try_get_report_url_if_needed( logger.debug('Failed to get report URL', exc_info=e) -def wait_for_detections_creation( - cycode_client: 'ScanClient', scan_type: str, scan_id: str, expected_detections_count: int -) -> None: - logger.debug('Waiting for detections to be created') - - scan_persisted_detections_count = 0 - polling_timeout = consts.DETECTIONS_COUNT_VERIFICATION_TIMEOUT_IN_SECONDS - end_polling_time = time.time() + polling_timeout - - while time.time() < end_polling_time: - scan_persisted_detections_count = cycode_client.get_scan_detections_count(scan_type, scan_id) - logger.debug( - 'Excepting %s detections, got %s detections (%s more; %s seconds left)', - expected_detections_count, - scan_persisted_detections_count, - expected_detections_count - scan_persisted_detections_count, - round(end_polling_time - time.time()), - ) - if scan_persisted_detections_count == expected_detections_count: - return - - time.sleep(consts.DETECTIONS_COUNT_VERIFICATION_WAIT_INTERVAL_IN_SECONDS) - - logger.debug('%s detections has been created', scan_persisted_detections_count) - raise custom_exceptions.ScanAsyncError( - f'Failed to wait for detections to be created after {polling_timeout} seconds' - ) - - def _map_detections_per_file(detections: List[dict]) -> List[DetectionsPerFile]: detections_per_files = {} for detection in detections: diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 132b45b9..4e7b4556 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -151,8 +151,6 @@ SCAN_POLLING_WAIT_INTERVAL_IN_SECONDS = 5 DEFAULT_SCAN_POLLING_TIMEOUT_IN_SECONDS = 3600 SCAN_POLLING_TIMEOUT_IN_SECONDS_ENV_VAR_NAME = 'SCAN_POLLING_TIMEOUT_IN_SECONDS' -DETECTIONS_COUNT_VERIFICATION_TIMEOUT_IN_SECONDS = 600 -DETECTIONS_COUNT_VERIFICATION_WAIT_INTERVAL_IN_SECONDS = 10 DEFAULT_SCA_PRE_COMMIT_TIMEOUT_IN_SECONDS = 600 SCA_PRE_COMMIT_TIMEOUT_IN_SECONDS_ENV_VAR_NAME = 'SCA_PRE_COMMIT_TIMEOUT_IN_SECONDS' diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index 9431c9a3..37bf5d62 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -245,15 +245,6 @@ def get_scan_detections(self, scan_type: str, scan_id: str) -> List[dict]: return detections - def get_scan_detections_count_path(self, scan_type: str) -> str: - return f'{self.get_scan_detections_path(scan_type)}/count' - - def get_scan_detections_count(self, scan_type: str, scan_id: str) -> int: - response = self.scan_cycode_client.get( - url_path=self.get_scan_detections_count_path(scan_type), params={'scan_id': scan_id} - ) - return response.json().get('count', 0) - def commit_range_zipped_file_scan( self, scan_type: str, zip_file: InMemoryZip, scan_id: str ) -> models.ZippedFileScanResult: diff --git a/tests/cyclient/mocked_responses/scan_client.py b/tests/cyclient/mocked_responses/scan_client.py index f6b20194..87643001 100644 --- a/tests/cyclient/mocked_responses/scan_client.py +++ b/tests/cyclient/mocked_responses/scan_client.py @@ -135,18 +135,6 @@ def get_scan_details_response(url: str, scan_id: Optional[UUID] = None) -> respo return responses.Response(method=responses.GET, url=url, json=json_response, status=200) -def get_scan_detections_count_url(scan_client: ScanClient) -> str: - api_url = scan_client.scan_cycode_client.api_url - service_url = scan_client.get_scan_detections_count_path() - return f'{api_url}/{service_url}' - - -def get_scan_detections_count_response(url: str) -> responses.Response: - json_response = {'count': 1} - - return responses.Response(method=responses.GET, url=url, json=json_response, status=200) - - def get_scan_detections_url(scan_client: ScanClient, scan_type: str) -> str: api_url = scan_client.scan_cycode_client.api_url service_url = scan_client.get_scan_detections_path(scan_type) @@ -195,7 +183,6 @@ def mock_scan_async_responses( ) responses_module.add(get_scan_details_response(get_scan_details_url(scan_id, scan_client), scan_id)) responses_module.add(get_detection_rules_response(get_detection_rules_url(scan_client))) - responses_module.add(get_scan_detections_count_response(get_scan_detections_count_url(scan_client))) responses_module.add( get_scan_detections_response(get_scan_detections_url(scan_client, scan_type), scan_id, zip_content_path) ) From 45a2360d6251465854afbfa73907a9050413ee1e Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Fri, 28 Jun 2024 13:03:38 +0200 Subject: [PATCH 097/257] CM-37517 - Properly handle permission denied exception (#233) --- cycode/cli/utils/path_utils.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/cycode/cli/utils/path_utils.py b/cycode/cli/utils/path_utils.py index e0cedc88..02b0fcc6 100644 --- a/cycode/cli/utils/path_utils.py +++ b/cycode/cli/utils/path_utils.py @@ -3,7 +3,9 @@ from functools import lru_cache from typing import AnyStr, List, Optional -from binaryornot.check import is_binary +from binaryornot.helpers import is_binary_string + +from cycode.cyclient import logger @lru_cache(maxsize=None) @@ -22,8 +24,28 @@ def get_absolute_path(path: str) -> str: return os.path.abspath(path) +def _get_starting_chunk(filename: str, length: int = 1024) -> Optional[bytes]: + # We are using our own implementation of get_starting_chunk + # because the original one from binaryornot uses print()... + + try: + with open(filename, 'rb') as f: + return f.read(length) + except IOError as e: + logger.debug('Failed to read the starting chunk from file: %s', filename, exc_info=e) + + return None + + def is_binary_file(filename: str) -> bool: - return is_binary(filename) + # Check if the file extension is in a list of known binary types + binary_extensions = ('.pyc',) + if filename.endswith(binary_extensions): + return True + + # Check if the starting chunk is a binary string + chunk = _get_starting_chunk(filename) + return is_binary_string(chunk) def get_file_size(filename: str) -> int: @@ -56,6 +78,8 @@ def get_file_content(file_path: str) -> Optional[AnyStr]: return f.read() except (FileNotFoundError, UnicodeDecodeError): return None + except PermissionError: + logger.warn('Permission denied to read the file: %s', file_path) def load_json(txt: str) -> Optional[dict]: From 6916896fbe435230d77461e81d85e297a939b5a3 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 9 Jul 2024 12:16:48 +0200 Subject: [PATCH 098/257] CM-37862 - Integrate Sentry (#235) --- cycode/cli/commands/auth/auth_command.py | 25 ++++- .../commands/configure/configure_command.py | 3 + cycode/cli/commands/ignore/ignore_command.py | 3 + cycode/cli/commands/report/report_command.py | 2 + .../commands/report/sbom/path/path_command.py | 3 + .../repository_url/repository_url_command.py | 3 + .../cli/commands/report/sbom/sbom_command.py | 3 + .../commit_history/commit_history_command.py | 3 + cycode/cli/commands/scan/path/path_command.py | 3 + .../scan/pre_commit/pre_commit_command.py | 3 + .../scan/pre_receive/pre_receive_command.py | 3 + .../scan/repository/repository_command.py | 3 + .../commands/scan/scan_ci/scan_ci_command.py | 2 + cycode/cli/commands/scan/scan_command.py | 5 + cycode/cli/consts.py | 9 ++ .../exceptions/handle_report_sbom_errors.py | 3 + cycode/cli/exceptions/handle_scan_errors.py | 10 +- cycode/cli/main.py | 4 + cycode/cli/models.py | 1 + cycode/cli/printers/json_printer.py | 2 +- cycode/cli/printers/text_printer.py | 7 ++ cycode/cli/sentry.py | 99 +++++++++++++++++++ .../cli/user_settings/credentials_manager.py | 5 + cycode/cli/utils/jwt_utils.py | 14 +++ cycode/cyclient/headers.py | 7 +- poetry.lock | 74 +++++++++++++- pyproject.toml | 2 + tests/conftest.py | 3 +- 28 files changed, 291 insertions(+), 13 deletions(-) create mode 100644 cycode/cli/sentry.py create mode 100644 cycode/cli/utils/jwt_utils.py 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' From 4f7f0f20262f2dc3275a79e87a048af72ae13688 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Jul 2024 12:19:56 +0200 Subject: [PATCH 099/257] Bump certifi from 2023.11.17 to 2024.7.4 (#234) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index f8550921..2d1dba3d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -42,13 +42,13 @@ chardet = ">=3.0.2" [[package]] name = "certifi" -version = "2023.11.17" +version = "2024.7.4" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, - {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, + {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, + {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, ] [[package]] @@ -892,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 = "1cc189cf2949bc14816bd8afbbb33bf980ad15a3f203fbe1f811cb4bc1bbd052" +content-hash = "4a6413d0b55b143fb6628ff7e27f7dd99ac3722ed6a9ad249ed9102dbaf1d284" From 4f3e62cbe9e589383ba646256f7c42e921fddde1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Jul 2024 15:28:33 +0200 Subject: [PATCH 100/257] Bump urllib3 from 1.26.18 to 1.26.19 (#231) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 10 +++++----- pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/poetry.lock b/poetry.lock index 2d1dba3d..41d092d4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -860,13 +860,13 @@ files = [ [[package]] name = "urllib3" -version = "1.26.18" +version = "1.26.19" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" files = [ - {file = "urllib3-1.26.18-py2.py3-none-any.whl", hash = "sha256:34b97092d7e0a3a8cf7cd10e386f401b3737364026c45e622aa02903dffe0f07"}, - {file = "urllib3-1.26.18.tar.gz", hash = "sha256:f8ecc1bba5667413457c529ab955bf8c67b45db799d159066261719e328580a0"}, + {file = "urllib3-1.26.19-py2.py3-none-any.whl", hash = "sha256:37a0344459b199fce0e80b0d3569837ec6b6937435c5244e7fd73fa6006830f3"}, + {file = "urllib3-1.26.19.tar.gz", hash = "sha256:3e3d753a8618b86d7de333b4223005f68720bcd6a7d2bcb9fbd2229ec7c1e429"}, ] [package.extras] @@ -892,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 = "4a6413d0b55b143fb6628ff7e27f7dd99ac3722ed6a9ad249ed9102dbaf1d284" +content-hash = "02e26455e3dc3f405b006d9257940e98a70ce9a0bce7324d570652df8d452427" diff --git a/pyproject.toml b/pyproject.toml index 3bdbffa7..e8c85eba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ arrow = ">=1.0.0,<1.3.0" 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 +urllib3 = "1.26.19" # 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" From 996d4055dc4c23b72e2c3806f8544fbb5fa9c624 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Thu, 18 Jul 2024 10:35:47 +0200 Subject: [PATCH 101/257] CM-38264 - Fix certification validation (#236) --- cycode/cli/main.py | 4 ++ poetry.lock | 134 ++++++++++++++++++++++++++++++++++++++------- pyproject.toml | 1 + 3 files changed, 119 insertions(+), 20 deletions(-) diff --git a/cycode/cli/main.py b/cycode/cli/main.py index d312723e..5cd13937 100644 --- a/cycode/cli/main.py +++ b/cycode/cli/main.py @@ -1,5 +1,9 @@ from multiprocessing import freeze_support +# DO NOT REMOVE OR MOVE THIS LINE +# this is required to use certificates system store with requests packaged with PyInstaller +import pip_system_certs.wrapt_requests # noqa: F401 + from cycode.cli.commands.main_cli import main_cli from cycode.cli.sentry import add_breadcrumb, init_sentry diff --git a/poetry.lock b/poetry.lock index 41d092d4..d7977ec8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -276,13 +276,13 @@ packaging = ">=20.9" [[package]] name = "exceptiongroup" -version = "1.2.0" +version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, - {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, ] [package.extras] @@ -304,13 +304,13 @@ smmap = ">=3.0.1,<6" [[package]] name = "gitpython" -version = "3.1.41" +version = "3.1.43" description = "GitPython is a Python library used to interact with Git repositories" optional = false python-versions = ">=3.7" files = [ - {file = "GitPython-3.1.41-py3-none-any.whl", hash = "sha256:c36b6634d069b3f719610175020a9aed919421c87552185b085e04fbbdb10b7c"}, - {file = "GitPython-3.1.41.tar.gz", hash = "sha256:ed66e624884f76df22c8e16066d567aaa5a37d5b5fa19db2c6df6f7156db9048"}, + {file = "GitPython-3.1.43-py3-none-any.whl", hash = "sha256:eec7ec56b92aad751f9912a73404bc02ba212a23adb2c7098ee668417051a1ff"}, + {file = "GitPython-3.1.43.tar.gz", hash = "sha256:35f314a9f878467f5453cc1fee295c3e18e52f1b99f10f6cf5b1682e968a9e7c"}, ] [package.dependencies] @@ -318,7 +318,8 @@ gitdb = ">=4.0.1,<5" typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\""} [package.extras] -test = ["black", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "sumtypes"] +doc = ["sphinx (==4.3.2)", "sphinx-autodoc-typehints", "sphinx-rtd-theme", "sphinxcontrib-applehelp (>=1.0.2,<=1.0.4)", "sphinxcontrib-devhelp (==1.0.2)", "sphinxcontrib-htmlhelp (>=2.0.0,<=2.0.1)", "sphinxcontrib-qthelp (==1.0.3)", "sphinxcontrib-serializinghtml (==1.1.5)"] +test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions"] [[package]] name = "idna" @@ -414,13 +415,13 @@ test = ["pytest (<5.4)", "pytest-cov"] [[package]] name = "packaging" -version = "23.2" +version = "24.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.7" files = [ - {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, - {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, + {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, + {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, ] [[package]] @@ -445,6 +446,20 @@ files = [ {file = "pefile-2023.2.7.tar.gz", hash = "sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc"}, ] +[[package]] +name = "pip-system-certs" +version = "4.0" +description = "Live patches pip to use system certs by default" +optional = false +python-versions = ">=2.7.9, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "pip_system_certs-4.0-py2.py3-none-any.whl", hash = "sha256:47202b9403a6f40783a9674bbc8873f5fc86544ec01a49348fa913e99e2ff68b"}, + {file = "pip_system_certs-4.0.tar.gz", hash = "sha256:db8e6a31388d9795ec9139957df1a89fa5274fb66164456fd091a5d3e94c350c"}, +] + +[package.dependencies] +wrapt = ">=1.10.4" + [[package]] name = "pluggy" version = "1.2.0" @@ -499,13 +514,13 @@ hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"] [[package]] name = "pyinstaller-hooks-contrib" -version = "2024.0" +version = "2024.7" description = "Community maintained hooks for PyInstaller" optional = false python-versions = ">=3.7" files = [ - {file = "pyinstaller-hooks-contrib-2024.0.tar.gz", hash = "sha256:a7118c1a5c9788595e5c43ad058a7a5b7b6d59e1eceb42362f6ec1f0b61986b0"}, - {file = "pyinstaller_hooks_contrib-2024.0-py2.py3-none-any.whl", hash = "sha256:469b5690df53223e2e8abffb2e44d6ee596e7d79d4b1eed9465123b67439875a"}, + {file = "pyinstaller_hooks_contrib-2024.7-py2.py3-none-any.whl", hash = "sha256:8bf0775771fbaf96bcd2f4dfd6f7ae6c1dd1b1efe254c7e50477b3c08e7841d8"}, + {file = "pyinstaller_hooks_contrib-2024.7.tar.gz", hash = "sha256:fd5f37dcf99bece184e40642af88be16a9b89613ecb958a8bd1136634fc9fac5"}, ] [package.dependencies] @@ -575,13 +590,13 @@ dev = ["pre-commit", "pytest-asyncio", "tox"] [[package]] name = "python-dateutil" -version = "2.8.2" +version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ - {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, - {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, ] [package.dependencies] @@ -728,13 +743,13 @@ files = [ [[package]] name = "sentry-sdk" -version = "2.8.0" +version = "2.10.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"}, + {file = "sentry_sdk-2.10.0-py2.py3-none-any.whl", hash = "sha256:87b3d413c87d8e7f816cc9334bff255a83d8b577db2b22042651c30c19c09190"}, + {file = "sentry_sdk-2.10.0.tar.gz", hash = "sha256:545fcc6e36c335faa6d6cda84669b6e17025f31efbf3b2211ec14efe008b75d1"}, ] [package.dependencies] @@ -874,6 +889,85 @@ brotli = ["brotli (==1.0.9)", "brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotl secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] +[[package]] +name = "wrapt" +version = "1.16.0" +description = "Module for decorators, wrappers and monkey patching." +optional = false +python-versions = ">=3.6" +files = [ + {file = "wrapt-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ffa565331890b90056c01db69c0fe634a776f8019c143a5ae265f9c6bc4bd6d4"}, + {file = "wrapt-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4fdb9275308292e880dcbeb12546df7f3e0f96c6b41197e0cf37d2826359020"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb2dee3874a500de01c93d5c71415fcaef1d858370d405824783e7a8ef5db440"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a88e6010048489cda82b1326889ec075a8c856c2e6a256072b28eaee3ccf487"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac83a914ebaf589b69f7d0a1277602ff494e21f4c2f743313414378f8f50a4cf"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:73aa7d98215d39b8455f103de64391cb79dfcad601701a3aa0dddacf74911d72"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:807cc8543a477ab7422f1120a217054f958a66ef7314f76dd9e77d3f02cdccd0"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bf5703fdeb350e36885f2875d853ce13172ae281c56e509f4e6eca049bdfb136"}, + {file = "wrapt-1.16.0-cp310-cp310-win32.whl", hash = "sha256:f6b2d0c6703c988d334f297aa5df18c45e97b0af3679bb75059e0e0bd8b1069d"}, + {file = "wrapt-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:decbfa2f618fa8ed81c95ee18a387ff973143c656ef800c9f24fb7e9c16054e2"}, + {file = "wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09"}, + {file = "wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d"}, + {file = "wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362"}, + {file = "wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89"}, + {file = "wrapt-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b"}, + {file = "wrapt-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c"}, + {file = "wrapt-1.16.0-cp312-cp312-win32.whl", hash = "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc"}, + {file = "wrapt-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8"}, + {file = "wrapt-1.16.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d462f28826f4657968ae51d2181a074dfe03c200d6131690b7d65d55b0f360f8"}, + {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a33a747400b94b6d6b8a165e4480264a64a78c8a4c734b62136062e9a248dd39"}, + {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3646eefa23daeba62643a58aac816945cadc0afaf21800a1421eeba5f6cfb9c"}, + {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ebf019be5c09d400cf7b024aa52b1f3aeebeff51550d007e92c3c1c4afc2a40"}, + {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:0d2691979e93d06a95a26257adb7bfd0c93818e89b1406f5a28f36e0d8c1e1fc"}, + {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:1acd723ee2a8826f3d53910255643e33673e1d11db84ce5880675954183ec47e"}, + {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:bc57efac2da352a51cc4658878a68d2b1b67dbe9d33c36cb826ca449d80a8465"}, + {file = "wrapt-1.16.0-cp36-cp36m-win32.whl", hash = "sha256:da4813f751142436b075ed7aa012a8778aa43a99f7b36afe9b742d3ed8bdc95e"}, + {file = "wrapt-1.16.0-cp36-cp36m-win_amd64.whl", hash = "sha256:6f6eac2360f2d543cc875a0e5efd413b6cbd483cb3ad7ebf888884a6e0d2e966"}, + {file = "wrapt-1.16.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a0ea261ce52b5952bf669684a251a66df239ec6d441ccb59ec7afa882265d593"}, + {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bd2d7ff69a2cac767fbf7a2b206add2e9a210e57947dd7ce03e25d03d2de292"}, + {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9159485323798c8dc530a224bd3ffcf76659319ccc7bbd52e01e73bd0241a0c5"}, + {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a86373cf37cd7764f2201b76496aba58a52e76dedfaa698ef9e9688bfd9e41cf"}, + {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:73870c364c11f03ed072dda68ff7aea6d2a3a5c3fe250d917a429c7432e15228"}, + {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b935ae30c6e7400022b50f8d359c03ed233d45b725cfdd299462f41ee5ffba6f"}, + {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:db98ad84a55eb09b3c32a96c576476777e87c520a34e2519d3e59c44710c002c"}, + {file = "wrapt-1.16.0-cp37-cp37m-win32.whl", hash = "sha256:9153ed35fc5e4fa3b2fe97bddaa7cbec0ed22412b85bcdaf54aeba92ea37428c"}, + {file = "wrapt-1.16.0-cp37-cp37m-win_amd64.whl", hash = "sha256:66dfbaa7cfa3eb707bbfcd46dab2bc6207b005cbc9caa2199bcbc81d95071a00"}, + {file = "wrapt-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1dd50a2696ff89f57bd8847647a1c363b687d3d796dc30d4dd4a9d1689a706f0"}, + {file = "wrapt-1.16.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:44a2754372e32ab315734c6c73b24351d06e77ffff6ae27d2ecf14cf3d229202"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e9723528b9f787dc59168369e42ae1c3b0d3fadb2f1a71de14531d321ee05b0"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbed418ba5c3dce92619656802cc5355cb679e58d0d89b50f116e4a9d5a9603e"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:941988b89b4fd6b41c3f0bfb20e92bd23746579736b7343283297c4c8cbae68f"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6a42cd0cfa8ffc1915aef79cb4284f6383d8a3e9dcca70c445dcfdd639d51267"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ca9b6085e4f866bd584fb135a041bfc32cab916e69f714a7d1d397f8c4891ca"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5e49454f19ef621089e204f862388d29e6e8d8b162efce05208913dde5b9ad6"}, + {file = "wrapt-1.16.0-cp38-cp38-win32.whl", hash = "sha256:c31f72b1b6624c9d863fc095da460802f43a7c6868c5dda140f51da24fd47d7b"}, + {file = "wrapt-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:490b0ee15c1a55be9c1bd8609b8cecd60e325f0575fc98f50058eae366e01f41"}, + {file = "wrapt-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9b201ae332c3637a42f02d1045e1d0cccfdc41f1f2f801dafbaa7e9b4797bfc2"}, + {file = "wrapt-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2076fad65c6736184e77d7d4729b63a6d1ae0b70da4868adeec40989858eb3fb"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5cd603b575ebceca7da5a3a251e69561bec509e0b46e4993e1cac402b7247b8"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b47cfad9e9bbbed2339081f4e346c93ecd7ab504299403320bf85f7f85c7d46c"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8212564d49c50eb4565e502814f694e240c55551a5f1bc841d4fcaabb0a9b8a"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5f15814a33e42b04e3de432e573aa557f9f0f56458745c2074952f564c50e664"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db2e408d983b0e61e238cf579c09ef7020560441906ca990fe8412153e3b291f"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:edfad1d29c73f9b863ebe7082ae9321374ccb10879eeabc84ba3b69f2579d537"}, + {file = "wrapt-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed867c42c268f876097248e05b6117a65bcd1e63b779e916fe2e33cd6fd0d3c3"}, + {file = "wrapt-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:eb1b046be06b0fce7249f1d025cd359b4b80fc1c3e24ad9eca33e0dcdb2e4a35"}, + {file = "wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1"}, + {file = "wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d"}, +] + [[package]] name = "zipp" version = "3.15.0" @@ -892,4 +986,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = ">=3.7,<3.13" -content-hash = "02e26455e3dc3f405b006d9257940e98a70ce9a0bce7324d570652df8d452427" +content-hash = "44a2febfab3d771ac993515ab3ab03f835a530be30bfca8a9735789e1354a72c" diff --git a/pyproject.toml b/pyproject.toml index e8c85eba..ff789418 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ requests = ">=2.24,<3.0" urllib3 = "1.26.19" # 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" +pip-system-certs = ">=4.0,<5.0" [tool.poetry.group.test.dependencies] mock = ">=4.0.3,<4.1.0" From be7938865399a9d1593123dc79f96091efecf8c0 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Thu, 18 Jul 2024 10:43:47 +0200 Subject: [PATCH 102/257] CM-38309 - Cover optional System Git Executable with more tests (#237) --- cycode/cli/utils/git_proxy.py | 27 +++++++++++++++++- tests/cli/commands/test_main_command.py | 37 +++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/cycode/cli/utils/git_proxy.py b/cycode/cli/utils/git_proxy.py index 4143c657..5d535657 100644 --- a/cycode/cli/utils/git_proxy.py +++ b/cycode/cli/utils/git_proxy.py @@ -73,4 +73,29 @@ def get_git_proxy(git_module: Optional[types.ModuleType]) -> _AbstractGitProxy: return _GitProxy() if git_module else _DummyGitProxy() -git_proxy = get_git_proxy(git) +class GitProxyManager(_AbstractGitProxy): + """We are using this manager for easy unit testing and mocking of the git module.""" + + def __init__(self) -> None: + self._git_proxy = get_git_proxy(git) + + def _set_dummy_git_proxy(self) -> None: + self._git_proxy = _DummyGitProxy() + + def _set_git_proxy(self) -> None: + self._git_proxy = _GitProxy() + + def get_repo(self, path: Optional['PathLike'] = None, *args, **kwargs) -> 'Repo': + return self._git_proxy.get_repo(path, *args, **kwargs) + + def get_null_tree(self) -> object: + return self._git_proxy.get_null_tree() + + def get_invalid_git_repository_error(self) -> Type[BaseException]: + return self._git_proxy.get_invalid_git_repository_error() + + def get_git_command_error(self) -> Type[BaseException]: + return self._git_proxy.get_git_command_error() + + +git_proxy = GitProxyManager() diff --git a/tests/cli/commands/test_main_command.py b/tests/cli/commands/test_main_command.py index d74a2c40..32a55972 100644 --- a/tests/cli/commands/test_main_command.py +++ b/tests/cli/commands/test_main_command.py @@ -7,6 +7,7 @@ from click.testing import CliRunner from cycode.cli.commands.main_cli import main_cli +from cycode.cli.utils.git_proxy import git_proxy from tests.conftest import CLI_ENV_VARS, TEST_FILES_PATH, ZIP_CONTENT_PATH from tests.cyclient.mocked_responses.scan_client import mock_scan_responses from tests.cyclient.test_scan_client import get_zipped_file_scan_response, get_zipped_file_scan_url @@ -47,3 +48,39 @@ def test_passing_output_option(output: str, scan_client: 'ScanClient', api_token assert 'scan_id' in output else: assert 'Scan ID' in result.output + + +@responses.activate +def test_optional_git_with_path_scan(scan_client: 'ScanClient', api_token_response: responses.Response) -> None: + mock_scan_responses(responses, 'secret', scan_client, uuid4(), ZIP_CONTENT_PATH) + responses.add(get_zipped_file_scan_response(get_zipped_file_scan_url('secret', scan_client), ZIP_CONTENT_PATH)) + responses.add(api_token_response) + + # fake env without Git executable + git_proxy._set_dummy_git_proxy() + + args = ['--output', 'json', 'scan', 'path', str(_PATH_TO_SCAN)] + result = CliRunner().invoke(main_cli, args, env=CLI_ENV_VARS) + + # do NOT expect error about not found Git executable + assert 'GIT_PYTHON_GIT_EXECUTABLE' not in result.output + + # reset the git proxy + git_proxy._set_git_proxy() + + +@responses.activate +def test_required_git_with_path_repository(scan_client: 'ScanClient', api_token_response: responses.Response) -> None: + responses.add(api_token_response) + + # fake env without Git executable + git_proxy._set_dummy_git_proxy() + + args = ['--output', 'json', 'scan', 'repository', str(_PATH_TO_SCAN)] + result = CliRunner().invoke(main_cli, args, env=CLI_ENV_VARS) + + # expect error about not found Git executable + assert 'GIT_PYTHON_GIT_EXECUTABLE' in result.output + + # reset the git proxy + git_proxy._set_git_proxy() From c7b77f074f628bb8c4bd49d4c9db8d0d10882701 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Thu, 18 Jul 2024 15:48:23 +0200 Subject: [PATCH 103/257] CM-38320 - Fix invalid access token exception (#238) --- .github/workflows/tests.yml | 3 ++- .github/workflows/tests_full.yml | 1 + cycode/cli/commands/main_cli.py | 4 ++++ cycode/cli/main.py | 4 ---- cycode/cli/sentry.py | 2 +- cycode/cli/utils/jwt_utils.py | 17 +++++++++++------ 6 files changed, 19 insertions(+), 12 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 114169e4..7e43c4e7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -20,9 +20,10 @@ jobs: files.pythonhosted.org install.python-poetry.org pypi.org + *.ingest.us.sentry.io - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index 524bd22b..e6b13632 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -33,6 +33,7 @@ jobs: files.pythonhosted.org install.python-poetry.org pypi.org + *.ingest.us.sentry.io - name: Checkout repository uses: actions/checkout@v4 diff --git a/cycode/cli/commands/main_cli.py b/cycode/cli/commands/main_cli.py index e418cf7f..5a81cbb9 100644 --- a/cycode/cli/commands/main_cli.py +++ b/cycode/cli/commands/main_cli.py @@ -12,6 +12,7 @@ from cycode.cli.consts import ( CLI_CONTEXT_SETTINGS, ) +from cycode.cli.sentry import add_breadcrumb, init_sentry from cycode.cli.user_settings.configuration_manager import ConfigurationManager from cycode.cli.utils.progress_bar import SCAN_PROGRESS_BAR_SECTIONS, get_progress_bar from cycode.cyclient.config import set_logging_level @@ -60,6 +61,9 @@ def main_cli( context: click.Context, verbose: bool, no_progress_meter: bool, output: str, user_agent: Optional[str] ) -> None: + init_sentry() + add_breadcrumb('cycode') + context.ensure_object(dict) configuration_manager = ConfigurationManager() diff --git a/cycode/cli/main.py b/cycode/cli/main.py index 5cd13937..0a7b9a15 100644 --- a/cycode/cli/main.py +++ b/cycode/cli/main.py @@ -5,7 +5,6 @@ import pip_system_certs.wrapt_requests # noqa: F401 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 @@ -13,7 +12,4 @@ # 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/sentry.py b/cycode/cli/sentry.py index ea6ffd49..44e55945 100644 --- a/cycode/cli/sentry.py +++ b/cycode/cli/sentry.py @@ -51,6 +51,7 @@ def init_sentry() -> None: dsn=consts.SENTRY_DSN, debug=consts.SENTRY_DEBUG, release=_get_sentry_release(), + server_name='', before_send=_before_sentry_event_send, sample_rate=consts.SENTRY_SAMPLE_RATE, send_default_pii=consts.SENTRY_SEND_DEFAULT_PII, @@ -61,7 +62,6 @@ def init_sentry() -> None: 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: diff --git a/cycode/cli/utils/jwt_utils.py b/cycode/cli/utils/jwt_utils.py index 743570e2..7bb7df62 100644 --- a/cycode/cli/utils/jwt_utils.py +++ b/cycode/cli/utils/jwt_utils.py @@ -1,14 +1,19 @@ -from typing import Tuple +from typing import Optional, Tuple import jwt +_JWT_PAYLOAD_POSSIBLE_USER_ID_FIELD_NAMES = ('userId', 'internalId', 'token-user-id') -def get_user_and_tenant_ids_from_access_token(access_token: str) -> Tuple[str, str]: + +def get_user_and_tenant_ids_from_access_token(access_token: str) -> Tuple[Optional[str], Optional[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') + user_id = None + for field in _JWT_PAYLOAD_POSSIBLE_USER_ID_FIELD_NAMES: + user_id = payload.get(field) + if user_id: + break + + tenant_id = payload.get('tenantId') return user_id, tenant_id From 9f588fcbd24964b5579ba905b65db31877e2d955 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Mon, 22 Jul 2024 14:23:58 +0200 Subject: [PATCH 104/257] CM-38374 - Disable Sentry for on-premise installations; fix CLI config loading (#239) --- cycode/cli/consts.py | 5 +-- cycode/cli/sentry.py | 15 ++++++++- cycode/cli/user_settings/base_file_manager.py | 5 +-- cycode/cli/utils/yaml_utils.py | 33 ++++++++++++------- cycode/cyclient/config.py | 7 ++++ tests/cyclient/test_config.py | 24 ++++++++++++++ 6 files changed, 71 insertions(+), 18 deletions(-) create mode 100644 tests/cyclient/test_config.py diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 27730720..65db60c4 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -107,8 +107,9 @@ COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES = [PRE_RECEIVE_COMMAND_SCAN_TYPE, COMMIT_HISTORY_COMMAND_SCAN_TYPE] -DEFAULT_CYCODE_API_URL = 'https://api.cycode.com' -DEFAULT_CYCODE_APP_URL = 'https://app.cycode.com' +DEFAULT_CYCODE_DOMAIN = 'cycode.com' +DEFAULT_CYCODE_API_URL = f'https://api.{DEFAULT_CYCODE_DOMAIN}' +DEFAULT_CYCODE_APP_URL = f'https://app.{DEFAULT_CYCODE_DOMAIN}' # env var names CYCODE_API_URL_ENV_VAR_NAME = 'CYCODE_API_URL' diff --git a/cycode/cli/sentry.py b/cycode/cli/sentry.py index 44e55945..e132bcf8 100644 --- a/cycode/cli/sentry.py +++ b/cycode/cli/sentry.py @@ -4,12 +4,16 @@ import sentry_sdk from sentry_sdk.integrations.atexit import AtexitIntegration +from sentry_sdk.integrations.dedupe import DedupeIntegration +from sentry_sdk.integrations.excepthook import ExcepthookIntegration +from sentry_sdk.integrations.logging import LoggingIntegration 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 +from cycode.cyclient.config import on_premise_installation # when Sentry is blocked on the machine, we want to keep clean output without retries warnings logging.getLogger('urllib3.connectionpool').setLevel(logging.ERROR) @@ -36,9 +40,14 @@ def _get_sentry_local_release() -> str: _SENTRY_LOCAL_RELEASE = _get_sentry_local_release() +_SENTRY_DISABLED = on_premise_installation def _before_sentry_event_send(event: dict, _: dict) -> Optional[dict]: + if _SENTRY_DISABLED: + # drop all events when Sentry is disabled + return None + if event.get('release') == _SENTRY_LOCAL_RELEASE: logger.debug('Dropping Sentry event due to local development setup') return None @@ -58,8 +67,12 @@ def init_sentry() -> None: 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), + default_integrations=False, integrations=[ - AtexitIntegration(lambda _, __: None) # disable output to stderr about pending events + AtexitIntegration(lambda _, __: None), # disable output to stderr about pending events + ExcepthookIntegration(), + DedupeIntegration(), + LoggingIntegration(), ], ) diff --git a/cycode/cli/user_settings/base_file_manager.py b/cycode/cli/user_settings/base_file_manager.py index 37c9b0de..778b8d23 100644 --- a/cycode/cli/user_settings/base_file_manager.py +++ b/cycode/cli/user_settings/base_file_manager.py @@ -11,10 +11,7 @@ def get_filename(self) -> str: ... def read_file(self) -> Dict[Hashable, Any]: - try: - return read_file(self.get_filename()) - except FileNotFoundError: - return {} + return read_file(self.get_filename()) def write_content_to_file(self, content: Dict[Hashable, Any]) -> None: filename = self.get_filename() diff --git a/cycode/cli/utils/yaml_utils.py b/cycode/cli/utils/yaml_utils.py index 3f910537..251b6c24 100644 --- a/cycode/cli/utils/yaml_utils.py +++ b/cycode/cli/utils/yaml_utils.py @@ -1,23 +1,33 @@ -from typing import Any, Dict, Hashable +import os +from typing import Any, Dict, Hashable, TextIO import yaml +def _yaml_safe_load(file: TextIO) -> Dict[Hashable, Any]: + # loader.get_single_data could return None + loaded_file = yaml.safe_load(file) + if loaded_file is None: + return {} + + return loaded_file + + def read_file(filename: str) -> Dict[Hashable, Any]: - with open(filename, 'r', encoding='UTF-8') as file: - return yaml.safe_load(file) + if not os.path.exists(filename): + return {} + with open(filename, 'r', encoding='UTF-8') as file: + return _yaml_safe_load(file) -def update_file(filename: str, content: Dict[Hashable, Any]) -> None: - try: - with open(filename, 'r', encoding='UTF-8') as file: - file_content = yaml.safe_load(file) - except FileNotFoundError: - file_content = {} +def write_file(filename: str, content: Dict[Hashable, Any]) -> None: with open(filename, 'w', encoding='UTF-8') as file: - file_content = _deep_update(file_content, content) - yaml.safe_dump(file_content, file) + yaml.safe_dump(content, file) + + +def update_file(filename: str, content: Dict[Hashable, Any]) -> None: + write_file(filename, _deep_update(read_file(filename), content)) def _deep_update(source: Dict[Hashable, Any], overrides: Dict[Hashable, Any]) -> Dict[Hashable, Any]: @@ -26,4 +36,5 @@ def _deep_update(source: Dict[Hashable, Any], overrides: Dict[Hashable, Any]) -> source[key] = _deep_update(source.get(key, {}), value) else: source[key] = overrides[key] + return source diff --git a/cycode/cyclient/config.py b/cycode/cyclient/config.py index 926723d1..37183195 100644 --- a/cycode/cyclient/config.py +++ b/cycode/cyclient/config.py @@ -106,6 +106,13 @@ def is_valid_url(url: str) -> bool: ) cycode_api_url = consts.DEFAULT_CYCODE_API_URL + +def _is_on_premise_installation(cycode_domain: str) -> bool: + return not cycode_api_url.endswith(cycode_domain) + + +on_premise_installation = _is_on_premise_installation(consts.DEFAULT_CYCODE_DOMAIN) + timeout = get_val_as_int(consts.CYCODE_CLI_REQUEST_TIMEOUT_ENV_VAR_NAME) if not timeout: timeout = get_val_as_int(consts.TIMEOUT_ENV_VAR_NAME) diff --git a/tests/cyclient/test_config.py b/tests/cyclient/test_config.py new file mode 100644 index 00000000..c78a5f5d --- /dev/null +++ b/tests/cyclient/test_config.py @@ -0,0 +1,24 @@ +from typing import TYPE_CHECKING + +from cycode.cli.consts import DEFAULT_CYCODE_DOMAIN +from cycode.cyclient.config import _is_on_premise_installation + +if TYPE_CHECKING: + from _pytest.monkeypatch import MonkeyPatch + + +def test_is_on_premise_installation(monkeypatch: 'MonkeyPatch') -> None: + monkeypatch.setattr('cycode.cyclient.config.cycode_api_url', 'api.cycode.com') + assert not _is_on_premise_installation(DEFAULT_CYCODE_DOMAIN) + monkeypatch.setattr('cycode.cyclient.config.cycode_api_url', 'api.eu.cycode.com') + assert not _is_on_premise_installation(DEFAULT_CYCODE_DOMAIN) + + monkeypatch.setattr('cycode.cyclient.config.cycode_api_url', 'cycode.google.com') + assert _is_on_premise_installation(DEFAULT_CYCODE_DOMAIN) + monkeypatch.setattr('cycode.cyclient.config.cycode_api_url', 'cycode.blabla.google.com') + assert _is_on_premise_installation(DEFAULT_CYCODE_DOMAIN) + + monkeypatch.setattr('cycode.cyclient.config.cycode_api_url', 'api.cycode.com') + assert _is_on_premise_installation('blabla') + monkeypatch.setattr('cycode.cyclient.config.cycode_api_url', 'cycode.blabla.google.com') + assert _is_on_premise_installation('blabla') From 9dbbddbe31f9985855d9054b7535b9a895932371 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 23 Jul 2024 12:32:23 +0200 Subject: [PATCH 105/257] CM-38506 - Fix usage of paths param (#240) --- .../sca/maven/base_restore_maven_dependencies.py | 2 +- cycode/cli/files_collector/sca/sca_code_scanner.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cycode/cli/files_collector/sca/maven/base_restore_maven_dependencies.py b/cycode/cli/files_collector/sca/maven/base_restore_maven_dependencies.py index e302e3a0..bd4c3215 100644 --- a/cycode/cli/files_collector/sca/maven/base_restore_maven_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/base_restore_maven_dependencies.py @@ -34,7 +34,7 @@ def restore(self, document: Document) -> Optional[Document]: def get_manifest_file_path(self, document: Document) -> str: return ( - join_paths(self.context.params.get('path'), document.path) + join_paths(self.context.params['paths'][0], document.path) if self.context.obj.get('monitor') else document.path ) diff --git a/cycode/cli/files_collector/sca/sca_code_scanner.py b/cycode/cli/files_collector/sca/sca_code_scanner.py index 0b94e941..7366bcbe 100644 --- a/cycode/cli/files_collector/sca/sca_code_scanner.py +++ b/cycode/cli/files_collector/sca/sca_code_scanner.py @@ -103,8 +103,8 @@ def try_restore_dependencies( logger.warning('Error occurred while trying to generate dependencies tree, %s', {'filename': document.path}) restore_dependencies_document.content = '' else: - is_monitor_action = context.obj.get('monitor') - project_path = context.params.get('path') + is_monitor_action = context.obj['monitor'] + project_path = context.params['paths'][0] manifest_file_path = get_manifest_file_path(document, is_monitor_action, project_path) logger.debug('Succeeded to generate dependencies tree on path: %s', manifest_file_path) From e260141f34e23c40c0ce6006f7fa602a3c241089 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 23 Jul 2024 17:03:10 +0200 Subject: [PATCH 106/257] CM-38532 - Fix certificate issues on macOS; fix homebrew build (#241) --- cycode/cli/main.py | 4 -- cycode/cyclient/cycode_client_base.py | 35 +++++++++- poetry.lock | 95 +-------------------------- pyproject.toml | 1 - 4 files changed, 34 insertions(+), 101 deletions(-) diff --git a/cycode/cli/main.py b/cycode/cli/main.py index 0a7b9a15..dd2d1fa7 100644 --- a/cycode/cli/main.py +++ b/cycode/cli/main.py @@ -1,9 +1,5 @@ from multiprocessing import freeze_support -# DO NOT REMOVE OR MOVE THIS LINE -# this is required to use certificates system store with requests packaged with PyInstaller -import pip_system_certs.wrapt_requests # noqa: F401 - from cycode.cli.commands.main_cli import main_cli if __name__ == '__main__': diff --git a/cycode/cyclient/cycode_client_base.py b/cycode/cyclient/cycode_client_base.py index fbf3a91c..c80ecaf7 100644 --- a/cycode/cyclient/cycode_client_base.py +++ b/cycode/cyclient/cycode_client_base.py @@ -1,12 +1,42 @@ -from typing import ClassVar, Dict, Optional +import os +import platform +import ssl +from typing import Callable, ClassVar, Dict, Optional -from requests import Response, exceptions, request +import requests +from requests import Response, exceptions +from requests.adapters import HTTPAdapter from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError, NetworkError from cycode.cyclient import config, logger from cycode.cyclient.headers import get_cli_user_agent, get_correlation_id +class SystemStorageSslContext(HTTPAdapter): + def init_poolmanager(self, *args, **kwargs) -> None: + default_context = ssl.create_default_context() + default_context.load_default_certs() + kwargs['ssl_context'] = default_context + return super().init_poolmanager(*args, **kwargs) + + def cert_verify(self, *args, **kwargs) -> None: + super().cert_verify(*args, **kwargs) + conn = kwargs['conn'] if 'conn' in kwargs else args[0] + conn.ca_certs = None + + +def _get_request_function() -> Callable: + if platform.system() == 'Darwin': + return requests.request + + if os.environ.get('REQUESTS_CA_BUNDLE') or os.environ.get('CURL_CA_BUNDLE'): + return requests.request + + session = requests.Session() + session.mount('https://', SystemStorageSslContext()) + return session.request + + class CycodeClientBase: MANDATORY_HEADERS: ClassVar[Dict[str, str]] = { 'User-Agent': get_cli_user_agent(), @@ -56,6 +86,7 @@ def _execute( try: headers = self.get_request_headers(headers, without_auth=without_auth) + request = _get_request_function() response = request(method=method, url=url, timeout=timeout, headers=headers, **kwargs) content = 'HIDDEN' if hide_response_content_log else response.text diff --git a/poetry.lock b/poetry.lock index d7977ec8..6bdda7f4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -446,20 +446,6 @@ files = [ {file = "pefile-2023.2.7.tar.gz", hash = "sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc"}, ] -[[package]] -name = "pip-system-certs" -version = "4.0" -description = "Live patches pip to use system certs by default" -optional = false -python-versions = ">=2.7.9, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "pip_system_certs-4.0-py2.py3-none-any.whl", hash = "sha256:47202b9403a6f40783a9674bbc8873f5fc86544ec01a49348fa913e99e2ff68b"}, - {file = "pip_system_certs-4.0.tar.gz", hash = "sha256:db8e6a31388d9795ec9139957df1a89fa5274fb66164456fd091a5d3e94c350c"}, -] - -[package.dependencies] -wrapt = ">=1.10.4" - [[package]] name = "pluggy" version = "1.2.0" @@ -889,85 +875,6 @@ brotli = ["brotli (==1.0.9)", "brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotl secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] -[[package]] -name = "wrapt" -version = "1.16.0" -description = "Module for decorators, wrappers and monkey patching." -optional = false -python-versions = ">=3.6" -files = [ - {file = "wrapt-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ffa565331890b90056c01db69c0fe634a776f8019c143a5ae265f9c6bc4bd6d4"}, - {file = "wrapt-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4fdb9275308292e880dcbeb12546df7f3e0f96c6b41197e0cf37d2826359020"}, - {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb2dee3874a500de01c93d5c71415fcaef1d858370d405824783e7a8ef5db440"}, - {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a88e6010048489cda82b1326889ec075a8c856c2e6a256072b28eaee3ccf487"}, - {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac83a914ebaf589b69f7d0a1277602ff494e21f4c2f743313414378f8f50a4cf"}, - {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:73aa7d98215d39b8455f103de64391cb79dfcad601701a3aa0dddacf74911d72"}, - {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:807cc8543a477ab7422f1120a217054f958a66ef7314f76dd9e77d3f02cdccd0"}, - {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bf5703fdeb350e36885f2875d853ce13172ae281c56e509f4e6eca049bdfb136"}, - {file = "wrapt-1.16.0-cp310-cp310-win32.whl", hash = "sha256:f6b2d0c6703c988d334f297aa5df18c45e97b0af3679bb75059e0e0bd8b1069d"}, - {file = "wrapt-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:decbfa2f618fa8ed81c95ee18a387ff973143c656ef800c9f24fb7e9c16054e2"}, - {file = "wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09"}, - {file = "wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d"}, - {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389"}, - {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060"}, - {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1"}, - {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3"}, - {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956"}, - {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d"}, - {file = "wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362"}, - {file = "wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89"}, - {file = "wrapt-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b"}, - {file = "wrapt-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36"}, - {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73"}, - {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809"}, - {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b"}, - {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81"}, - {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9"}, - {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c"}, - {file = "wrapt-1.16.0-cp312-cp312-win32.whl", hash = "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc"}, - {file = "wrapt-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8"}, - {file = "wrapt-1.16.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d462f28826f4657968ae51d2181a074dfe03c200d6131690b7d65d55b0f360f8"}, - {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a33a747400b94b6d6b8a165e4480264a64a78c8a4c734b62136062e9a248dd39"}, - {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3646eefa23daeba62643a58aac816945cadc0afaf21800a1421eeba5f6cfb9c"}, - {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ebf019be5c09d400cf7b024aa52b1f3aeebeff51550d007e92c3c1c4afc2a40"}, - {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:0d2691979e93d06a95a26257adb7bfd0c93818e89b1406f5a28f36e0d8c1e1fc"}, - {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:1acd723ee2a8826f3d53910255643e33673e1d11db84ce5880675954183ec47e"}, - {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:bc57efac2da352a51cc4658878a68d2b1b67dbe9d33c36cb826ca449d80a8465"}, - {file = "wrapt-1.16.0-cp36-cp36m-win32.whl", hash = "sha256:da4813f751142436b075ed7aa012a8778aa43a99f7b36afe9b742d3ed8bdc95e"}, - {file = "wrapt-1.16.0-cp36-cp36m-win_amd64.whl", hash = "sha256:6f6eac2360f2d543cc875a0e5efd413b6cbd483cb3ad7ebf888884a6e0d2e966"}, - {file = "wrapt-1.16.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a0ea261ce52b5952bf669684a251a66df239ec6d441ccb59ec7afa882265d593"}, - {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bd2d7ff69a2cac767fbf7a2b206add2e9a210e57947dd7ce03e25d03d2de292"}, - {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9159485323798c8dc530a224bd3ffcf76659319ccc7bbd52e01e73bd0241a0c5"}, - {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a86373cf37cd7764f2201b76496aba58a52e76dedfaa698ef9e9688bfd9e41cf"}, - {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:73870c364c11f03ed072dda68ff7aea6d2a3a5c3fe250d917a429c7432e15228"}, - {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b935ae30c6e7400022b50f8d359c03ed233d45b725cfdd299462f41ee5ffba6f"}, - {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:db98ad84a55eb09b3c32a96c576476777e87c520a34e2519d3e59c44710c002c"}, - {file = "wrapt-1.16.0-cp37-cp37m-win32.whl", hash = "sha256:9153ed35fc5e4fa3b2fe97bddaa7cbec0ed22412b85bcdaf54aeba92ea37428c"}, - {file = "wrapt-1.16.0-cp37-cp37m-win_amd64.whl", hash = "sha256:66dfbaa7cfa3eb707bbfcd46dab2bc6207b005cbc9caa2199bcbc81d95071a00"}, - {file = "wrapt-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1dd50a2696ff89f57bd8847647a1c363b687d3d796dc30d4dd4a9d1689a706f0"}, - {file = "wrapt-1.16.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:44a2754372e32ab315734c6c73b24351d06e77ffff6ae27d2ecf14cf3d229202"}, - {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e9723528b9f787dc59168369e42ae1c3b0d3fadb2f1a71de14531d321ee05b0"}, - {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbed418ba5c3dce92619656802cc5355cb679e58d0d89b50f116e4a9d5a9603e"}, - {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:941988b89b4fd6b41c3f0bfb20e92bd23746579736b7343283297c4c8cbae68f"}, - {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6a42cd0cfa8ffc1915aef79cb4284f6383d8a3e9dcca70c445dcfdd639d51267"}, - {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ca9b6085e4f866bd584fb135a041bfc32cab916e69f714a7d1d397f8c4891ca"}, - {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5e49454f19ef621089e204f862388d29e6e8d8b162efce05208913dde5b9ad6"}, - {file = "wrapt-1.16.0-cp38-cp38-win32.whl", hash = "sha256:c31f72b1b6624c9d863fc095da460802f43a7c6868c5dda140f51da24fd47d7b"}, - {file = "wrapt-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:490b0ee15c1a55be9c1bd8609b8cecd60e325f0575fc98f50058eae366e01f41"}, - {file = "wrapt-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9b201ae332c3637a42f02d1045e1d0cccfdc41f1f2f801dafbaa7e9b4797bfc2"}, - {file = "wrapt-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2076fad65c6736184e77d7d4729b63a6d1ae0b70da4868adeec40989858eb3fb"}, - {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5cd603b575ebceca7da5a3a251e69561bec509e0b46e4993e1cac402b7247b8"}, - {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b47cfad9e9bbbed2339081f4e346c93ecd7ab504299403320bf85f7f85c7d46c"}, - {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8212564d49c50eb4565e502814f694e240c55551a5f1bc841d4fcaabb0a9b8a"}, - {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5f15814a33e42b04e3de432e573aa557f9f0f56458745c2074952f564c50e664"}, - {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db2e408d983b0e61e238cf579c09ef7020560441906ca990fe8412153e3b291f"}, - {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:edfad1d29c73f9b863ebe7082ae9321374ccb10879eeabc84ba3b69f2579d537"}, - {file = "wrapt-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed867c42c268f876097248e05b6117a65bcd1e63b779e916fe2e33cd6fd0d3c3"}, - {file = "wrapt-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:eb1b046be06b0fce7249f1d025cd359b4b80fc1c3e24ad9eca33e0dcdb2e4a35"}, - {file = "wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1"}, - {file = "wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d"}, -] - [[package]] name = "zipp" version = "3.15.0" @@ -986,4 +893,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = ">=3.7,<3.13" -content-hash = "44a2febfab3d771ac993515ab3ab03f835a530be30bfca8a9735789e1354a72c" +content-hash = "02e26455e3dc3f405b006d9257940e98a70ce9a0bce7324d570652df8d452427" diff --git a/pyproject.toml b/pyproject.toml index ff789418..e8c85eba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,6 @@ requests = ">=2.24,<3.0" urllib3 = "1.26.19" # 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" -pip-system-certs = ">=4.0,<5.0" [tool.poetry.group.test.dependencies] mock = ">=4.0.3,<4.1.0" From cae810b8e18fcf4a63d47f69019fc7c5aaa27f77 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Thu, 25 Jul 2024 11:31:14 +0200 Subject: [PATCH 107/257] CM-38532, CM-38510 - Use CA system storage only on Windows (#242) --- cycode/cyclient/cycode_client_base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cycode/cyclient/cycode_client_base.py b/cycode/cyclient/cycode_client_base.py index c80ecaf7..cb2caad2 100644 --- a/cycode/cyclient/cycode_client_base.py +++ b/cycode/cyclient/cycode_client_base.py @@ -26,10 +26,10 @@ def cert_verify(self, *args, **kwargs) -> None: def _get_request_function() -> Callable: - if platform.system() == 'Darwin': + if os.environ.get('REQUESTS_CA_BUNDLE') or os.environ.get('CURL_CA_BUNDLE'): return requests.request - if os.environ.get('REQUESTS_CA_BUNDLE') or os.environ.get('CURL_CA_BUNDLE'): + if platform.system() != 'Windows': return requests.request session = requests.Session() From 23e24679ab1220afbbde337089c2faa774b33000 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Mon, 29 Jul 2024 16:10:40 +0200 Subject: [PATCH 108/257] CM-38567 - Capture certificate errors and inform the user of the env variable option (#244) --- cycode/cli/commands/auth/auth_command.py | 13 +++-- cycode/cli/exceptions/custom_exceptions.py | 49 ++++++++++++++++++- .../exceptions/handle_report_sbom_errors.py | 12 +---- cycode/cli/exceptions/handle_scan_errors.py | 14 +----- cycode/cyclient/auth_client.py | 4 +- cycode/cyclient/cycode_client_base.py | 30 +++++++----- .../cli/exceptions/test_handle_scan_errors.py | 2 +- tests/cyclient/test_auth_client.py | 6 +-- tests/cyclient/test_scan_client.py | 17 +++---- 9 files changed, 91 insertions(+), 56 deletions(-) diff --git a/cycode/cli/commands/auth/auth_command.py b/cycode/cli/commands/auth/auth_command.py index d7787ad5..4c377737 100644 --- a/cycode/cli/commands/auth/auth_command.py +++ b/cycode/cli/commands/auth/auth_command.py @@ -1,7 +1,12 @@ import click from cycode.cli.commands.auth.auth_manager import AuthManager -from cycode.cli.exceptions.custom_exceptions import AuthProcessError, HttpUnauthorizedError, NetworkError +from cycode.cli.exceptions.custom_exceptions import ( + KNOWN_USER_FRIENDLY_REQUEST_ERRORS, + AuthProcessError, + HttpUnauthorizedError, + RequestHttpError, +) from cycode.cli.models import CliError, CliErrors, CliResult from cycode.cli.printers import ConsolePrinter from cycode.cli.sentry import add_breadcrumb, capture_exception @@ -68,7 +73,7 @@ def authorization_check(context: click.Context) -> None: ) return - except (NetworkError, HttpUnauthorizedError): + except (RequestHttpError, HttpUnauthorizedError): ConsolePrinter(context).print_exception() printer.print_result(failed_auth_check_res) @@ -79,12 +84,10 @@ def _handle_exception(context: click.Context, e: Exception) -> None: ConsolePrinter(context).print_exception() errors: CliErrors = { + **KNOWN_USER_FRIENDLY_REQUEST_ERRORS, AuthProcessError: CliError( code='auth_error', message='Authentication failed. Please try again later using the command `cycode auth`' ), - NetworkError: CliError( - code='cycode_error', message='Authentication failed. Please try again later using the command `cycode auth`' - ), } error = errors.get(type(e)) diff --git a/cycode/cli/exceptions/custom_exceptions.py b/cycode/cli/exceptions/custom_exceptions.py index 1b218353..0b1b3608 100644 --- a/cycode/cli/exceptions/custom_exceptions.py +++ b/cycode/cli/exceptions/custom_exceptions.py @@ -1,11 +1,29 @@ from requests import Response +from cycode.cli.models import CliError, CliErrors + class CycodeError(Exception): """Base class for all custom exceptions""" -class NetworkError(CycodeError): +class RequestError(CycodeError): + ... + + +class RequestTimeout(RequestError): + ... + + +class RequestConnectionError(RequestError): + ... + + +class RequestSslError(RequestConnectionError): + ... + + +class RequestHttpError(RequestError): def __init__(self, status_code: int, error_message: str, response: Response) -> None: self.status_code = status_code self.error_message = error_message @@ -32,7 +50,7 @@ class ReportAsyncError(CycodeError): pass -class HttpUnauthorizedError(CycodeError): +class HttpUnauthorizedError(RequestError): def __init__(self, error_message: str, response: Response) -> None: self.status_code = 401 self.error_message = error_message @@ -68,3 +86,30 @@ def __init__(self, file_path: str) -> None: def __str__(self) -> str: return f'Error occurred while parsing terraform plan file. Path: {self.file_path}' + + +KNOWN_USER_FRIENDLY_REQUEST_ERRORS: CliErrors = { + RequestHttpError: CliError( + soft_fail=True, + code='cycode_error', + message='Cycode was unable to complete this scan. Please try again by executing the `cycode scan` command', + ), + RequestTimeout: CliError( + soft_fail=True, + code='timeout_error', + message='The request timed out. Please try again by executing the `cycode scan` command', + ), + HttpUnauthorizedError: CliError( + soft_fail=True, + code='auth_error', + message='Unable to authenticate to Cycode, your token is either invalid or has expired. ' + 'Please re-generate your token and reconfigure it by running the `cycode configure` command', + ), + RequestSslError: CliError( + soft_fail=True, + code='ssl_error', + message='An SSL error occurred when trying to connect to the Cycode API. ' + 'If you use an on-premises installation or a proxy that intercepts SSL traffic ' + 'you should use the CURL_CA_BUNDLE environment variable to specify path to a valid .pem or similar.', + ), +} diff --git a/cycode/cli/exceptions/handle_report_sbom_errors.py b/cycode/cli/exceptions/handle_report_sbom_errors.py index 5ce117e1..bfd407a0 100644 --- a/cycode/cli/exceptions/handle_report_sbom_errors.py +++ b/cycode/cli/exceptions/handle_report_sbom_errors.py @@ -3,6 +3,7 @@ import click from cycode.cli.exceptions import custom_exceptions +from cycode.cli.exceptions.custom_exceptions import KNOWN_USER_FRIENDLY_REQUEST_ERRORS from cycode.cli.models import CliError, CliErrors from cycode.cli.printers import ConsolePrinter from cycode.cli.sentry import capture_exception @@ -12,11 +13,7 @@ def handle_report_exception(context: click.Context, err: Exception) -> Optional[ ConsolePrinter(context).print_exception() errors: CliErrors = { - custom_exceptions.NetworkError: CliError( - code='cycode_error', - message='Cycode was unable to complete this report. ' - 'Please try again by executing the `cycode report` command', - ), + **KNOWN_USER_FRIENDLY_REQUEST_ERRORS, custom_exceptions.ScanAsyncError: CliError( code='report_error', message='Cycode was unable to complete this report. ' @@ -27,11 +24,6 @@ def handle_report_exception(context: click.Context, err: Exception) -> Optional[ message='Cycode was unable to complete this report. ' 'Please try again by executing the `cycode report` command', ), - custom_exceptions.HttpUnauthorizedError: CliError( - code='auth_error', - message='Unable to authenticate to Cycode, your token is either invalid or has expired. ' - 'Please re-generate your token and reconfigure it by running the `cycode configure` command', - ), } if type(err) in errors: diff --git a/cycode/cli/exceptions/handle_scan_errors.py b/cycode/cli/exceptions/handle_scan_errors.py index 6e0948f9..2790418a 100644 --- a/cycode/cli/exceptions/handle_scan_errors.py +++ b/cycode/cli/exceptions/handle_scan_errors.py @@ -3,6 +3,7 @@ import click from cycode.cli.exceptions import custom_exceptions +from cycode.cli.exceptions.custom_exceptions import KNOWN_USER_FRIENDLY_REQUEST_ERRORS from cycode.cli.models import CliError, CliErrors from cycode.cli.printers import ConsolePrinter from cycode.cli.sentry import capture_exception @@ -17,24 +18,13 @@ def handle_scan_exception( ConsolePrinter(context).print_exception(e) errors: CliErrors = { - custom_exceptions.NetworkError: CliError( - soft_fail=True, - code='cycode_error', - message='Cycode was unable to complete this scan. ' - 'Please try again by executing the `cycode scan` command', - ), + **KNOWN_USER_FRIENDLY_REQUEST_ERRORS, custom_exceptions.ScanAsyncError: CliError( soft_fail=True, code='scan_error', message='Cycode was unable to complete this scan. ' 'Please try again by executing the `cycode scan` command', ), - custom_exceptions.HttpUnauthorizedError: CliError( - soft_fail=True, - code='auth_error', - message='Unable to authenticate to Cycode, your token is either invalid or has expired. ' - 'Please re-generate your token and reconfigure it by running the `cycode configure` command', - ), custom_exceptions.ZipTooLargeError: CliError( soft_fail=True, code='zip_too_large_error', diff --git a/cycode/cyclient/auth_client.py b/cycode/cyclient/auth_client.py index 91f43ad1..20c80d13 100644 --- a/cycode/cyclient/auth_client.py +++ b/cycode/cyclient/auth_client.py @@ -2,7 +2,7 @@ from requests import Response -from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError, NetworkError +from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError, RequestHttpError from cycode.cyclient import models from cycode.cyclient.cycode_client import CycodeClient @@ -25,7 +25,7 @@ def get_api_token(self, session_id: str, code_verifier: str) -> Optional[models. try: response = self.cycode_client.post(url_path=path, body=body, hide_response_content_log=True) return self.parse_api_token_polling_response(response) - except (NetworkError, HttpUnauthorizedError) as e: + except (RequestHttpError, HttpUnauthorizedError) as e: return self.parse_api_token_polling_response(e.response) except Exception: return None diff --git a/cycode/cyclient/cycode_client_base.py b/cycode/cyclient/cycode_client_base.py index cb2caad2..3024de89 100644 --- a/cycode/cyclient/cycode_client_base.py +++ b/cycode/cyclient/cycode_client_base.py @@ -7,7 +7,14 @@ from requests import Response, exceptions from requests.adapters import HTTPAdapter -from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError, NetworkError +from cycode.cli.exceptions.custom_exceptions import ( + HttpUnauthorizedError, + RequestConnectionError, + RequestError, + RequestHttpError, + RequestSslError, + RequestTimeout, +) from cycode.cyclient import config, logger from cycode.cyclient.headers import get_cli_user_agent, get_correlation_id @@ -110,18 +117,19 @@ def build_full_url(self, url: str, endpoint: str) -> str: def _handle_exception(self, e: Exception) -> None: if isinstance(e, exceptions.Timeout): - raise NetworkError(504, 'Timeout Error', e.response) - + raise RequestTimeout from e if isinstance(e, exceptions.HTTPError): - self._handle_http_exception(e) - elif isinstance(e, exceptions.ConnectionError): - raise NetworkError(502, 'Connection Error', e.response) - else: - raise e + raise self._get_http_exception(e) from e + if isinstance(e, exceptions.SSLError): + raise RequestSslError from e + if isinstance(e, exceptions.ConnectionError): + raise RequestConnectionError from e + + raise e @staticmethod - def _handle_http_exception(e: exceptions.HTTPError) -> None: + def _get_http_exception(e: exceptions.HTTPError) -> RequestError: if e.response.status_code == 401: - raise HttpUnauthorizedError(e.response.text, e.response) + return HttpUnauthorizedError(e.response.text, e.response) - raise NetworkError(e.response.status_code, e.response.text, e.response) + return RequestHttpError(e.response.status_code, e.response.text, e.response) diff --git a/tests/cli/exceptions/test_handle_scan_errors.py b/tests/cli/exceptions/test_handle_scan_errors.py index 7130bb1b..82a44bb0 100644 --- a/tests/cli/exceptions/test_handle_scan_errors.py +++ b/tests/cli/exceptions/test_handle_scan_errors.py @@ -21,7 +21,7 @@ def ctx() -> click.Context: @pytest.mark.parametrize( 'exception, expected_soft_fail', [ - (custom_exceptions.NetworkError(400, 'msg', Response()), True), + (custom_exceptions.RequestHttpError(400, 'msg', Response()), True), (custom_exceptions.ScanAsyncError('msg'), True), (custom_exceptions.HttpUnauthorizedError('msg', Response()), True), (custom_exceptions.ZipTooLargeError(1000), True), diff --git a/tests/cyclient/test_auth_client.py b/tests/cyclient/test_auth_client.py index 51618e3c..1d7e6683 100644 --- a/tests/cyclient/test_auth_client.py +++ b/tests/cyclient/test_auth_client.py @@ -4,7 +4,7 @@ from requests import Timeout from cycode.cli.commands.auth.auth_manager import AuthManager -from cycode.cli.exceptions.custom_exceptions import CycodeError +from cycode.cli.exceptions.custom_exceptions import CycodeError, RequestTimeout from cycode.cyclient.auth_client import AuthClient from cycode.cyclient.models import ( ApiTokenGenerationPollingResponse, @@ -73,11 +73,9 @@ def test_start_session_timeout(client: AuthClient, start_url: str, code_challeng responses.add(responses.POST, start_url, body=timeout_error) - with pytest.raises(CycodeError) as e_info: + with pytest.raises(RequestTimeout): client.start_session(code_challenge) - assert e_info.value.status_code == 504 - @responses.activate def test_start_session_http_error(client: AuthClient, start_url: str, code_challenge: str) -> None: diff --git a/tests/cyclient/test_scan_client.py b/tests/cyclient/test_scan_client.py index 0b92a0bf..d51e43f6 100644 --- a/tests/cyclient/test_scan_client.py +++ b/tests/cyclient/test_scan_client.py @@ -9,7 +9,12 @@ from requests.exceptions import ProxyError from cycode.cli.config import config -from cycode.cli.exceptions.custom_exceptions import CycodeError, HttpUnauthorizedError +from cycode.cli.exceptions.custom_exceptions import ( + CycodeError, + HttpUnauthorizedError, + RequestConnectionError, + RequestTimeout, +) from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip from cycode.cli.files_collector.zip_documents import zip_documents from cycode.cli.models import Document @@ -138,12 +143,9 @@ def test_zipped_file_scan_timeout_error( responses.add(api_token_response) # mock token based client responses.add(method=responses.POST, url=scan_url, body=timeout_error, status=504) - with pytest.raises(CycodeError) as e_info: + with pytest.raises(RequestTimeout): scan_client.zipped_file_scan(scan_type, zip_file, scan_id=expected_scan_id, scan_parameters={}) - assert e_info.value.status_code == 504 - assert e_info.value.error_message == 'Timeout Error' - @pytest.mark.parametrize('scan_type', config['scans']['supported_scans']) @responses.activate @@ -156,8 +158,5 @@ def test_zipped_file_scan_connection_error( responses.add(api_token_response) # mock token based client responses.add(method=responses.POST, url=url, body=ProxyError()) - with pytest.raises(CycodeError) as e_info: + with pytest.raises(RequestConnectionError): scan_client.zipped_file_scan(scan_type, zip_file, scan_id=expected_scan_id, scan_parameters={}) - - assert e_info.value.status_code == 502 - assert e_info.value.error_message == 'Connection Error' From 053f82a50d6f3f0819a6d2f841a8e0cdc8dd85fb Mon Sep 17 00:00:00 2001 From: Avishai Amiel <93073140+avishaiamiel@users.noreply.github.com> Date: Wed, 14 Aug 2024 21:41:38 +0300 Subject: [PATCH 109/257] =?UTF-8?q?CM-39300=20-=20Add=20unique=20value=20t?= =?UTF-8?q?o=20the=20resource=20name=20while=20building=20terra=E2=80=A6?= =?UTF-8?q?=20(#245)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cycode/cli/files_collector/iac/tf_content_generator.py | 3 ++- tests/cli/files_collector/iac/test_tf_content_generator.py | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/cycode/cli/files_collector/iac/tf_content_generator.py b/cycode/cli/files_collector/iac/tf_content_generator.py index 4df6d827..15fbf258 100644 --- a/cycode/cli/files_collector/iac/tf_content_generator.py +++ b/cycode/cli/files_collector/iac/tf_content_generator.py @@ -1,5 +1,6 @@ import json import time +import uuid from typing import List from cycode.cli import consts @@ -43,7 +44,7 @@ def _generate_tf_content(resource_changes: List[ResourceChange]) -> str: def _generate_resource_content(resource_change: ResourceChange) -> str: - resource_content = f'resource "{resource_change.resource_type}" "{resource_change.name}" {{\n' + resource_content = f'resource "{resource_change.resource_type}" "{resource_change.name}-{uuid.uuid4()}" {{\n' if resource_change.values is not None: for key, value in resource_change.values.items(): resource_content += f' {key} = {json.dumps(value)}\n' diff --git a/tests/cli/files_collector/iac/test_tf_content_generator.py b/tests/cli/files_collector/iac/test_tf_content_generator.py index 7953ed81..1e9b58c0 100644 --- a/tests/cli/files_collector/iac/test_tf_content_generator.py +++ b/tests/cli/files_collector/iac/test_tf_content_generator.py @@ -1,4 +1,5 @@ import os +import re from cycode.cli.files_collector.iac import tf_content_generator from cycode.cli.utils.path_utils import get_file_content, get_immediate_subdirectories @@ -13,4 +14,6 @@ def test_generate_tf_content_from_tfplan() -> None: tfplan_content = get_file_content(os.path.join(_PATH_TO_EXAMPLES, example, 'tfplan.json')) tf_expected_content = get_file_content(os.path.join(_PATH_TO_EXAMPLES, example, 'tf_content.txt')) tf_content = tf_content_generator.generate_tf_content_from_tfplan(example, tfplan_content) - assert tf_content == tf_expected_content + + cleaned_tf_content = re.sub(r'-[a-fA-F0-9\-]{36}', '', tf_content) + assert cleaned_tf_content == tf_expected_content From 0bfb8d4ba825c5a299b4eb2c6f03875332dc3f33 Mon Sep 17 00:00:00 2001 From: Avishai Amiel <93073140+avishaiamiel@users.noreply.github.com> Date: Mon, 19 Aug 2024 13:16:25 +0300 Subject: [PATCH 110/257] CM-3937 - Extend tfplan scan result with module path (#246) --- .../files_collector/iac/tf_content_generator.py | 16 ++++++++++++++-- cycode/cli/models.py | 2 ++ .../iac/test_tf_content_generator.py | 4 +--- .../tfplan-create-example/tf_content.txt | 4 ++-- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/cycode/cli/files_collector/iac/tf_content_generator.py b/cycode/cli/files_collector/iac/tf_content_generator.py index 15fbf258..ed2ef63d 100644 --- a/cycode/cli/files_collector/iac/tf_content_generator.py +++ b/cycode/cli/files_collector/iac/tf_content_generator.py @@ -1,6 +1,5 @@ import json import time -import uuid from typing import List from cycode.cli import consts @@ -44,7 +43,7 @@ def _generate_tf_content(resource_changes: List[ResourceChange]) -> str: def _generate_resource_content(resource_change: ResourceChange) -> str: - resource_content = f'resource "{resource_change.resource_type}" "{resource_change.name}-{uuid.uuid4()}" {{\n' + resource_content = f'resource "{resource_change.resource_type}" "{_get_resource_name(resource_change)}" {{\n' if resource_change.values is not None: for key, value in resource_change.values.items(): resource_content += f' {key} = {json.dumps(value)}\n' @@ -52,6 +51,17 @@ def _generate_resource_content(resource_change: ResourceChange) -> str: return resource_content +def _get_resource_name(resource_change: ResourceChange) -> str: + parts = [resource_change.module_address, resource_change.name] + + if resource_change.index is not None: + parts.append(str(resource_change.index)) + + valid_parts = [part for part in parts if part] + + return '-'.join(valid_parts).replace('.', '-') + + def _extract_resources(tfplan: str, filename: str) -> List[ResourceChange]: tfplan_json = load_json(tfplan) resources: List[ResourceChange] = [] @@ -60,8 +70,10 @@ def _extract_resources(tfplan: str, filename: str) -> List[ResourceChange]: for resource_change in resource_changes: resources.append( ResourceChange( + module_address=resource_change.get('module_address'), resource_type=resource_change['type'], name=resource_change['name'], + index=resource_change.get('index'), actions=resource_change['change']['actions'], values=resource_change['change']['after'], ) diff --git a/cycode/cli/models.py b/cycode/cli/models.py index 08b812bc..66846725 100644 --- a/cycode/cli/models.py +++ b/cycode/cli/models.py @@ -76,8 +76,10 @@ class LocalScanResult(NamedTuple): @dataclass class ResourceChange: + module_address: Optional[str] resource_type: str name: str + index: Optional[int] actions: List[str] values: Dict[str, str] diff --git a/tests/cli/files_collector/iac/test_tf_content_generator.py b/tests/cli/files_collector/iac/test_tf_content_generator.py index 1e9b58c0..369fc936 100644 --- a/tests/cli/files_collector/iac/test_tf_content_generator.py +++ b/tests/cli/files_collector/iac/test_tf_content_generator.py @@ -1,5 +1,4 @@ import os -import re from cycode.cli.files_collector.iac import tf_content_generator from cycode.cli.utils.path_utils import get_file_content, get_immediate_subdirectories @@ -15,5 +14,4 @@ def test_generate_tf_content_from_tfplan() -> None: tf_expected_content = get_file_content(os.path.join(_PATH_TO_EXAMPLES, example, 'tf_content.txt')) tf_content = tf_content_generator.generate_tf_content_from_tfplan(example, tfplan_content) - cleaned_tf_content = re.sub(r'-[a-fA-F0-9\-]{36}', '', tf_content) - assert cleaned_tf_content == tf_expected_content + assert tf_content == tf_expected_content diff --git a/tests/test_files/tf_content_generator_files/tfplan-create-example/tf_content.txt b/tests/test_files/tf_content_generator_files/tfplan-create-example/tf_content.txt index 4021097e..fa83af43 100644 --- a/tests/test_files/tf_content_generator_files/tfplan-create-example/tf_content.txt +++ b/tests/test_files/tf_content_generator_files/tfplan-create-example/tf_content.txt @@ -79,7 +79,7 @@ resource "aws_sfn_state_machine" "terra_ci_runner" { type = "STANDARD" } -resource "aws_route" "private" { +resource "aws_route" "private-1" { carrier_gateway_id = null destination_cidr_block = "172.25.16.0/20" destination_ipv6_cidr_block = null @@ -95,7 +95,7 @@ resource "aws_route" "private" { vpc_peering_connection_id = null } -resource "aws_route" "private" { +resource "aws_route" "private-rtb-00cf8381520103cfb" { carrier_gateway_id = null destination_cidr_block = "172.25.16.0/20" destination_ipv6_cidr_block = null From 17627da7904106629aba3bbcab353f2012175964 Mon Sep 17 00:00:00 2001 From: Avishai Amiel <93073140+avishaiamiel@users.noreply.github.com> Date: Wed, 21 Aug 2024 18:42:38 +0300 Subject: [PATCH 111/257] CM-39486 - Change unique resource ID in tfplan scan (#247) --- cycode/cli/files_collector/iac/tf_content_generator.py | 2 +- .../tfplan-create-example/tf_content.txt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cycode/cli/files_collector/iac/tf_content_generator.py b/cycode/cli/files_collector/iac/tf_content_generator.py index ed2ef63d..57ebb4b1 100644 --- a/cycode/cli/files_collector/iac/tf_content_generator.py +++ b/cycode/cli/files_collector/iac/tf_content_generator.py @@ -59,7 +59,7 @@ def _get_resource_name(resource_change: ResourceChange) -> str: valid_parts = [part for part in parts if part] - return '-'.join(valid_parts).replace('.', '-') + return '.'.join(valid_parts) def _extract_resources(tfplan: str, filename: str) -> List[ResourceChange]: diff --git a/tests/test_files/tf_content_generator_files/tfplan-create-example/tf_content.txt b/tests/test_files/tf_content_generator_files/tfplan-create-example/tf_content.txt index fa83af43..1a1761b8 100644 --- a/tests/test_files/tf_content_generator_files/tfplan-create-example/tf_content.txt +++ b/tests/test_files/tf_content_generator_files/tfplan-create-example/tf_content.txt @@ -79,7 +79,7 @@ resource "aws_sfn_state_machine" "terra_ci_runner" { type = "STANDARD" } -resource "aws_route" "private-1" { +resource "aws_route" "private.1" { carrier_gateway_id = null destination_cidr_block = "172.25.16.0/20" destination_ipv6_cidr_block = null @@ -95,7 +95,7 @@ resource "aws_route" "private-1" { vpc_peering_connection_id = null } -resource "aws_route" "private-rtb-00cf8381520103cfb" { +resource "aws_route" "private.rtb-00cf8381520103cfb" { carrier_gateway_id = null destination_cidr_block = "172.25.16.0/20" destination_ipv6_cidr_block = null From 9123b44de194f23cb91a3ffed2eb185ebe3d8116 Mon Sep 17 00:00:00 2001 From: Avishai Amiel <93073140+avishaiamiel@users.noreply.github.com> Date: Wed, 28 Aug 2024 17:12:13 +0300 Subject: [PATCH 112/257] CM-35768, CM-32588 - Support secrets commit range scans with `--report` option (#248) --- cycode/cli/commands/scan/code_scanner.py | 7 +++++-- cycode/cyclient/scan_client.py | 7 ++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/cycode/cli/commands/scan/code_scanner.py b/cycode/cli/commands/scan/code_scanner.py index 67045fba..293c94d8 100644 --- a/cycode/cli/commands/scan/code_scanner.py +++ b/cycode/cli/commands/scan/code_scanner.py @@ -487,7 +487,7 @@ def perform_scan( return perform_scan_sync(cycode_client, zipped_documents, scan_type, scan_parameters) if scan_type in (consts.SCA_SCAN_TYPE, consts.SAST_SCAN_TYPE) or should_use_scan_service: - return perform_scan_async(cycode_client, zipped_documents, scan_type, scan_parameters) + return perform_scan_async(cycode_client, zipped_documents, scan_type, scan_parameters, is_commit_range) if is_commit_range: return cycode_client.commit_range_zipped_file_scan(scan_type, zipped_documents, scan_id) @@ -500,8 +500,11 @@ def perform_scan_async( zipped_documents: 'InMemoryZip', scan_type: str, scan_parameters: dict, + is_commit_range: bool, ) -> ZippedFileScanResult: - scan_async_result = cycode_client.zipped_file_scan_async(zipped_documents, scan_type, scan_parameters) + scan_async_result = cycode_client.zipped_file_scan_async( + zipped_documents, scan_type, scan_parameters, is_commit_range=is_commit_range + ) logger.debug('Async scan request has been triggered successfully, %s', {'scan_id': scan_async_result.scan_id}) return poll_scan_results( diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index 37bf5d62..ef98d4c1 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -126,11 +126,16 @@ def zipped_file_scan_async( scan_type: str, scan_parameters: dict, is_git_diff: bool = False, + is_commit_range: bool = False, ) -> models.ScanInitializationResponse: files = {'file': ('multiple_files_scan.zip', zip_file.read())} response = self.scan_cycode_client.post( url_path=self.get_zipped_file_scan_async_url_path(scan_type), - data={'is_git_diff': is_git_diff, 'scan_parameters': json.dumps(scan_parameters)}, + data={ + 'is_git_diff': is_git_diff, + 'scan_parameters': json.dumps(scan_parameters), + 'is_commit_range': is_commit_range, + }, files=files, ) return models.ScanInitializationResponseSchema().load(response.json()) From d312c1786538f29eb38d33b017bf46c1f6c1ef5e Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Thu, 29 Aug 2024 16:57:16 +0200 Subject: [PATCH 113/257] CM-32255, CM-35768 - Fix scan errors during commit history scans with `--report` option (#249) --- cycode/cli/commands/scan/code_scanner.py | 64 +++++++++++++++--------- cycode/cyclient/scan_client.py | 8 +-- 2 files changed, 44 insertions(+), 28 deletions(-) diff --git a/cycode/cli/commands/scan/code_scanner.py b/cycode/cli/commands/scan/code_scanner.py index 293c94d8..8196e92a 100644 --- a/cycode/cli/commands/scan/code_scanner.py +++ b/cycode/cli/commands/scan/code_scanner.py @@ -525,7 +525,7 @@ def perform_scan_sync( logger.debug('Sync scan request has been triggered successfully, %s', {'scan_id': scan_results.id}) return ZippedFileScanResult( did_detect=True, - detections_per_file=_map_detections_per_file(scan_results.detection_messages), + detections_per_file=_map_detections_per_file_and_commit_id(scan_results.detection_messages), scan_id=scan_results.id, ) @@ -870,11 +870,11 @@ def _get_scan_result( if not scan_details.detections_count: return init_default_scan_result(cycode_client, scan_id, scan_type, should_get_report) - scan_detections = cycode_client.get_scan_detections(scan_type, scan_id) + scan_raw_detections = cycode_client.get_scan_raw_detections(scan_type, scan_id) return ZippedFileScanResult( did_detect=True, - detections_per_file=_map_detections_per_file(scan_detections), + detections_per_file=_map_detections_per_file_and_commit_id(scan_raw_detections), scan_id=scan_id, report_url=_try_get_report_url_if_needed(cycode_client, should_get_report, scan_id, scan_type), ) @@ -904,42 +904,58 @@ def _try_get_report_url_if_needed( logger.debug('Failed to get report URL', exc_info=e) -def _map_detections_per_file(detections: List[dict]) -> List[DetectionsPerFile]: +def _map_detections_per_file_and_commit_id(raw_detections: List[dict]) -> List[DetectionsPerFile]: + """Converts list of detections (async flow) to list of DetectionsPerFile objects (sync flow). + + Args: + raw_detections: List of detections as is returned from the server. + + Note: + This method fakes server response structure + to be able to use the same logic for both async and sync scans. + + Note: + Aggregation is performed by file name and commit ID (if available) + """ detections_per_files = {} - for detection in detections: + for raw_detection in raw_detections: try: - detection['message'] = detection['correlation_message'] - file_name = _get_file_name_from_detection(detection) - if file_name is None: - logger.debug('File name is missing from detection with ID %s', detection.get('id')) - continue - if detections_per_files.get(file_name) is None: - detections_per_files[file_name] = [DetectionSchema().load(detection)] + # FIXME(MarshalX): investigate this field mapping + raw_detection['message'] = raw_detection['correlation_message'] + + file_name = _get_file_name_from_detection(raw_detection) + detection: Detection = DetectionSchema().load(raw_detection) + commit_id: Optional[str] = detection.detection_details.get('commit_id') # could be None + group_by_key = (file_name, commit_id) + + if group_by_key in detections_per_files: + detections_per_files[group_by_key].append(detection) else: - detections_per_files[file_name].append(DetectionSchema().load(detection)) + detections_per_files[group_by_key] = [detection] except Exception as e: logger.debug('Failed to parse detection', exc_info=e) continue return [ - DetectionsPerFile(file_name=file_name, detections=file_detections) - for file_name, file_detections in detections_per_files.items() + DetectionsPerFile(file_name=file_name, detections=file_detections, commit_id=commit_id) + for (file_name, commit_id), file_detections in detections_per_files.items() ] -def _get_file_name_from_detection(detection: dict) -> str: - if detection.get('category') == 'SAST': - return detection['detection_details']['file_path'] +def _get_file_name_from_detection(raw_detection: dict) -> str: + category = raw_detection.get('category') - if detection.get('category') == 'SecretDetection': - return _get_secret_file_name_from_detection(detection) + if category == 'SAST': + return raw_detection['detection_details']['file_path'] + if category == 'SecretDetection': + return _get_secret_file_name_from_detection(raw_detection) - return detection['detection_details']['file_name'] + return raw_detection['detection_details']['file_name'] -def _get_secret_file_name_from_detection(detection: dict) -> str: - file_path: str = detection['detection_details']['file_path'] - file_name: str = detection['detection_details']['file_name'] +def _get_secret_file_name_from_detection(raw_detection: dict) -> str: + file_path: str = raw_detection['detection_details']['file_path'] + file_name: str = raw_detection['detection_details']['file_name'] return os.path.join(file_path, file_name) diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index ef98d4c1..3adbd168 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -225,12 +225,12 @@ def get_scan_detections_list_path_suffix(scan_type: str) -> str: def get_scan_detections_list_path(self, scan_type: str) -> str: return f'{self.get_scan_detections_path(scan_type)}{self.get_scan_detections_list_path_suffix(scan_type)}' - def get_scan_detections(self, scan_type: str, scan_id: str) -> List[dict]: + def get_scan_raw_detections(self, scan_type: str, scan_id: str) -> List[dict]: params = {'scan_id': scan_id} page_size = 200 - detections = [] + raw_detections = [] page_number = 0 last_response_size = 0 @@ -243,12 +243,12 @@ def get_scan_detections(self, scan_type: str, scan_id: str) -> List[dict]: params=params, hide_response_content_log=self._hide_response_log, ).json() - detections.extend(response) + raw_detections.extend(response) page_number += 1 last_response_size = len(response) - return detections + return raw_detections def commit_range_zipped_file_scan( self, scan_type: str, zip_file: InMemoryZip, scan_id: str From 7e528eab2821af9dad7346944ec77a721dfad8c2 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Thu, 26 Sep 2024 13:46:50 +0200 Subject: [PATCH 114/257] CM-40289 - Publish docker image with Cycode CLI (#250) --- .github/workflows/docker-image-dev.yml | 38 ------------- .github/workflows/docker-image.yml | 74 +++++++++++++------------- Dockerfile | 12 ++--- 3 files changed, 44 insertions(+), 80 deletions(-) delete mode 100644 .github/workflows/docker-image-dev.yml diff --git a/.github/workflows/docker-image-dev.yml b/.github/workflows/docker-image-dev.yml deleted file mode 100644 index b0743afc..00000000 --- a/.github/workflows/docker-image-dev.yml +++ /dev/null @@ -1,38 +0,0 @@ -on: - push: - branches: dev - -permissions: - contents: read - -jobs: - main: - runs-on: ubuntu-latest - steps: - - - name: Checkout - uses: actions/checkout@v2 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v1 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - - name: Login to DockerHub Registry - env: - DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }} - DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }} - run: echo "$DOCKERHUB_PASSWORD" | docker login -u "$DOCKERHUB_USER" --password-stdin - - - name: Build and push - id: docker_build - uses: docker/build-push-action@v3 - with: - context: . - file: ./Dockerfile - push: true - tags: cycodehq/cycode_cli:dev - - - name: Image digest - run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 8912d969..1db2b804 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -1,46 +1,48 @@ +name: Build and Publish Docker Image. On dispatch event build the latest tag and push to Docker Hub + on: workflow_dispatch: - -permissions: - # Write permission needed for creating a tag. - contents: write + push: + tags: [ 'v*.*.*' ] jobs: - main: + docker: runs-on: ubuntu-latest + steps: - - - name: Checkout - uses: actions/checkout@v2 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v1 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - - name: Login to DockerHub Registry - env: - DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }} - DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }} - run: echo "$DOCKERHUB_PASSWORD" | docker login -u "$DOCKERHUB_USER" --password-stdin - - - name: Bump version - id: bump_version - uses: anothrNick/github-tag-action@1.36.0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DEFAULT_BUMP: minor - - - - name: Build and push + - name: Checkout repository + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Get latest release tag + id: latest_tag + run: | + LATEST_TAG=$(git describe --tags `git rev-list --tags --max-count=1`) + echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_OUTPUT + + - name: Check out latest release tag + if: ${{ github.event_name == 'workflow_dispatch' }} + run: | + git checkout ${{ steps.latest_tag.outputs.LATEST_TAG }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USER }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Build and push id: docker_build - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v6 with: context: . - file: ./Dockerfile + platforms: linux/amd64,linux/arm64 push: true - tags: cycodehq/cycode_cli:${{ steps.bump_version.outputs.new_tag }} - - - name: Image digest - run: echo ${{ steps.docker_build.outputs.digest }} + tags: cycodehq/cycode_cli:${{ steps.latest_tag.outputs.LATEST_TAG }},cycodehq/cycode_cli:latest diff --git a/Dockerfile b/Dockerfile index a2197817..1b3a5815 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,12 @@ -FROM python:3.8.16-alpine3.17 as base +FROM python:3.12.6-alpine3.20 AS base WORKDIR /usr/cycode/app -RUN apk add git=2.38.5-r0 +RUN apk add git=2.45.2-r0 -FROM base as builder -ENV POETRY_VERSION=1.4.2 +FROM base AS builder +ENV POETRY_VERSION=1.8.3 # deps are required to build cffi -RUN apk add --no-cache --virtual .build-deps gcc=12.2.1_git20220924-r4 libffi-dev=3.4.4-r0 musl-dev=1.2.3-r4 && \ +RUN apk add --no-cache --virtual .build-deps gcc=13.2.1_git20240309-r0 libffi-dev=3.4.6-r0 musl-dev=1.2.5-r0 && \ pip install --no-cache-dir "poetry==$POETRY_VERSION" "poetry-dynamic-versioning[plugin]" && \ apk del .build-deps gcc libffi-dev musl-dev @@ -19,7 +19,7 @@ RUN poetry config virtualenvs.in-project true && \ poetry --no-cache install --only=main --no-root && \ poetry build -FROM base as final +FROM base AS final COPY --from=builder /usr/cycode/app/dist ./ RUN pip install --no-cache-dir cycode*.whl From 70d3cce6f849a2bcde7c94321e36ab3e0a3e100e Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 1 Oct 2024 14:42:56 +0200 Subject: [PATCH 115/257] CM-40699, CM-40700 - Implement new sync flow for Secrets and IaC (#251) --- cycode/cli/commands/scan/code_scanner.py | 41 +++++++++++-------- .../scan/repository/repository_command.py | 7 ++-- cycode/cyclient/cycode_token_based_client.py | 9 +++- cycode/cyclient/scan_client.py | 27 +++++++++--- 4 files changed, 57 insertions(+), 27 deletions(-) diff --git a/cycode/cli/commands/scan/code_scanner.py b/cycode/cli/commands/scan/code_scanner.py index 8196e92a..409d4c3f 100644 --- a/cycode/cli/commands/scan/code_scanner.py +++ b/cycode/cli/commands/scan/code_scanner.py @@ -100,12 +100,17 @@ def _should_use_scan_service(scan_type: str, scan_parameters: dict) -> bool: return scan_type == consts.SECRET_SCAN_TYPE and scan_parameters.get('report') is True -def _should_use_sync_flow(scan_type: str, sync_option: bool, scan_parameters: Optional[dict] = None) -> bool: +def _should_use_sync_flow( + command_scan_type: str, scan_type: str, sync_option: bool, scan_parameters: Optional[dict] = None +) -> bool: if not sync_option: return False - if scan_type not in (consts.SCA_SCAN_TYPE,): - raise ValueError(f'Sync scan is not available for {scan_type} scan type.') + if command_scan_type not in {'path', 'repository'}: + raise ValueError(f'Sync flow is not available for "{command_scan_type}" command type. Remove --sync option.') + + if scan_type is consts.SAST_SCAN_TYPE: + raise ValueError('Sync scan is not available for SAST scan type.') if scan_parameters.get('report') is True: raise ValueError('You can not use sync flow with report option. Either remove "report" or "sync" option.') @@ -163,7 +168,7 @@ def _scan_batch_thread_func(batch: List[Document]) -> Tuple[str, CliError, Local scan_completed = False should_use_scan_service = _should_use_scan_service(scan_type, scan_parameters) - should_use_sync_flow = _should_use_sync_flow(scan_type, sync_option, scan_parameters) + should_use_sync_flow = _should_use_sync_flow(command_scan_type, scan_type, sync_option, scan_parameters) try: logger.debug('Preparing local files, %s', {'batch_size': len(batch)}) @@ -217,7 +222,7 @@ def _scan_batch_thread_func(batch: List[Document]) -> Tuple[str, CliError, Local zip_file_size, command_scan_type, error_message, - should_use_scan_service, + should_use_scan_service or should_use_sync_flow, # sync flow implies scan service ) return scan_id, error, local_scan_result @@ -359,6 +364,8 @@ def scan_commit_range_documents( scan_parameters: Optional[dict] = None, timeout: Optional[int] = None, ) -> None: + """Used by SCA only""" + cycode_client = context.obj['client'] scan_type = context.obj['scan_type'] severity_threshold = context.obj['severity_threshold'] @@ -484,7 +491,8 @@ def perform_scan( should_use_sync_flow: bool = False, ) -> ZippedFileScanResult: if should_use_sync_flow: - return perform_scan_sync(cycode_client, zipped_documents, scan_type, scan_parameters) + # it does not support commit range scans; should_use_sync_flow handles it + return perform_scan_sync(cycode_client, zipped_documents, scan_type, scan_parameters, is_git_diff) if scan_type in (consts.SCA_SCAN_TYPE, consts.SAST_SCAN_TYPE) or should_use_scan_service: return perform_scan_async(cycode_client, zipped_documents, scan_type, scan_parameters, is_commit_range) @@ -520,12 +528,13 @@ def perform_scan_sync( zipped_documents: 'InMemoryZip', scan_type: str, scan_parameters: dict, + is_git_diff: bool = False, ) -> ZippedFileScanResult: - scan_results = cycode_client.zipped_file_scan_sync(zipped_documents, scan_type, scan_parameters) + scan_results = cycode_client.zipped_file_scan_sync(zipped_documents, scan_type, scan_parameters, is_git_diff) logger.debug('Sync scan request has been triggered successfully, %s', {'scan_id': scan_results.id}) return ZippedFileScanResult( did_detect=True, - detections_per_file=_map_detections_per_file_and_commit_id(scan_results.detection_messages), + detections_per_file=_map_detections_per_file_and_commit_id(scan_type, scan_results.detection_messages), scan_id=scan_results.id, ) @@ -610,7 +619,7 @@ def get_document_detections( commit_id = detections_per_file.commit_id logger.debug( - 'Going to find the document of the violated file., %s', {'file_name': file_name, 'commit_id': commit_id} + 'Going to find the document of the violated file, %s', {'file_name': file_name, 'commit_id': commit_id} ) document = _get_document_by_file_name(documents_to_scan, file_name, commit_id) @@ -874,7 +883,7 @@ def _get_scan_result( return ZippedFileScanResult( did_detect=True, - detections_per_file=_map_detections_per_file_and_commit_id(scan_raw_detections), + detections_per_file=_map_detections_per_file_and_commit_id(scan_type, scan_raw_detections), scan_id=scan_id, report_url=_try_get_report_url_if_needed(cycode_client, should_get_report, scan_id, scan_type), ) @@ -904,7 +913,7 @@ def _try_get_report_url_if_needed( logger.debug('Failed to get report URL', exc_info=e) -def _map_detections_per_file_and_commit_id(raw_detections: List[dict]) -> List[DetectionsPerFile]: +def _map_detections_per_file_and_commit_id(scan_type: str, raw_detections: List[dict]) -> List[DetectionsPerFile]: """Converts list of detections (async flow) to list of DetectionsPerFile objects (sync flow). Args: @@ -923,7 +932,7 @@ def _map_detections_per_file_and_commit_id(raw_detections: List[dict]) -> List[D # FIXME(MarshalX): investigate this field mapping raw_detection['message'] = raw_detection['correlation_message'] - file_name = _get_file_name_from_detection(raw_detection) + file_name = _get_file_name_from_detection(scan_type, raw_detection) detection: Detection = DetectionSchema().load(raw_detection) commit_id: Optional[str] = detection.detection_details.get('commit_id') # could be None group_by_key = (file_name, commit_id) @@ -942,12 +951,10 @@ def _map_detections_per_file_and_commit_id(raw_detections: List[dict]) -> List[D ] -def _get_file_name_from_detection(raw_detection: dict) -> str: - category = raw_detection.get('category') - - if category == 'SAST': +def _get_file_name_from_detection(scan_type: str, raw_detection: dict) -> str: + if scan_type == consts.SAST_SCAN_TYPE: return raw_detection['detection_details']['file_path'] - if category == 'SecretDetection': + if scan_type == consts.SECRET_SCAN_TYPE: return _get_secret_file_name_from_detection(raw_detection) return raw_detection['detection_details']['file_name'] diff --git a/cycode/cli/commands/scan/repository/repository_command.py b/cycode/cli/commands/scan/repository/repository_command.py index cd6e9f71..87b8bbcc 100644 --- a/cycode/cli/commands/scan/repository/repository_command.py +++ b/cycode/cli/commands/scan/repository/repository_command.py @@ -53,11 +53,10 @@ def repository_command(context: click.Context, path: str, branch: str) -> None: documents_to_scan = exclude_irrelevant_documents_to_scan(scan_type, documents_to_scan) - perform_pre_scan_documents_actions(context, scan_type, documents_to_scan, is_git_diff=False) + perform_pre_scan_documents_actions(context, scan_type, documents_to_scan) logger.debug('Found all relevant files for scanning %s', {'path': path, 'branch': branch}) - scan_documents( - context, documents_to_scan, is_git_diff=False, scan_parameters=get_scan_parameters(context, (path,)) - ) + scan_parameters = get_scan_parameters(context, (path,)) + scan_documents(context, documents_to_scan, scan_parameters=scan_parameters) except Exception as e: handle_scan_exception(context, e) diff --git a/cycode/cyclient/cycode_token_based_client.py b/cycode/cyclient/cycode_token_based_client.py index d13ce62d..ef538c89 100644 --- a/cycode/cyclient/cycode_token_based_client.py +++ b/cycode/cyclient/cycode_token_based_client.py @@ -8,6 +8,12 @@ from cycode.cli.user_settings.jwt_creator import JwtCreator from cycode.cyclient.cycode_client import CycodeClient +_NGINX_PLAIN_ERRORS = [ + b'Invalid JWT Token', + b'JWT Token Needed', + b'JWT Token validation failed', +] + class CycodeTokenBasedClient(CycodeClient): """Send requests with JWT.""" @@ -82,7 +88,8 @@ def _execute( response = super()._execute(*args, **kwargs) # backend returns 200 and plain text. no way to catch it with .raise_for_status() - if response.status_code == 200 and response.content in {b'Invalid JWT Token\n\n', b'JWT Token Needed\n\n'}: + nginx_error_response = any(response.content.startswith(plain_error) for plain_error in _NGINX_PLAIN_ERRORS) + if response.status_code == 200 and nginx_error_response: # if cached token is invalid, try to refresh it and retry the request self.refresh_access_token() response = super()._execute(*args, **kwargs) diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index 3adbd168..2e494cb0 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -31,7 +31,7 @@ def __init__( self._hide_response_log = hide_response_log def get_scan_controller_path(self, scan_type: str, should_use_scan_service: bool = False) -> str: - if scan_type == consts.INFRA_CONFIGURATION_SCAN_TYPE: + if not should_use_scan_service and scan_type == consts.INFRA_CONFIGURATION_SCAN_TYPE: # we don't use async flow for IaC scan yet return self._SCAN_SERVICE_CONTROLLER_PATH if not should_use_scan_service and scan_type == consts.SECRET_SCAN_TYPE: @@ -106,14 +106,31 @@ def get_zipped_file_scan_async_url_path(self, scan_type: str, should_use_sync_fl ) return f'{scan_service_url_path}/{async_scan_type}/{async_entity_type}' + def get_zipped_file_scan_sync_url_path(self, scan_type: str) -> str: + server_scan_type = self.scan_config.get_async_scan_type(scan_type) + scan_service_url_path = self.get_scan_service_url_path( + scan_type, should_use_scan_service=True, should_use_sync_flow=True + ) + return f'{scan_service_url_path}/{server_scan_type}/repository' + def zipped_file_scan_sync( - self, zip_file: InMemoryZip, scan_type: str, scan_parameters: dict + self, + zip_file: InMemoryZip, + scan_type: str, + scan_parameters: dict, + is_git_diff: bool = False, ) -> models.ScanResultsSyncFlow: files = {'file': ('multiple_files_scan.zip', zip_file.read())} - del scan_parameters['report'] # BE raises validation error instead of ignoring it + + if 'report' in scan_parameters: + del scan_parameters['report'] # BE raises validation error instead of ignoring it + response = self.scan_cycode_client.post( - url_path=self.get_zipped_file_scan_async_url_path(scan_type, should_use_sync_flow=True), - data={'scan_parameters': json.dumps(scan_parameters)}, + url_path=self.get_zipped_file_scan_sync_url_path(scan_type), + data={ + 'is_git_diff': is_git_diff, + 'scan_parameters': json.dumps(scan_parameters), + }, files=files, hide_response_content_log=self._hide_response_log, timeout=60, From c3ffced90a825b0668f1d7d711e38a34b7a7dca3 Mon Sep 17 00:00:00 2001 From: naftalicy Date: Thu, 10 Oct 2024 17:04:09 +0300 Subject: [PATCH 116/257] CM-40908 - Implement NuGet restore support for SCA (#252) --- ...encies.py => base_restore_dependencies.py} | 6 ++--- .../sca/maven/restore_gradle_dependencies.py | 4 +-- .../sca/maven/restore_maven_dependencies.py | 6 ++--- .../cli/files_collector/sca/nuget/__init__.py | 0 .../sca/nuget/restore_nuget_dependencies.py | 27 +++++++++++++++++++ .../files_collector/sca/sca_code_scanner.py | 19 ++++++------- cycode/cli/utils/path_utils.py | 8 ++++++ 7 files changed, 53 insertions(+), 17 deletions(-) rename cycode/cli/files_collector/sca/{maven/base_restore_maven_dependencies.py => base_restore_dependencies.py} (93%) create mode 100644 cycode/cli/files_collector/sca/nuget/__init__.py create mode 100644 cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py diff --git a/cycode/cli/files_collector/sca/maven/base_restore_maven_dependencies.py b/cycode/cli/files_collector/sca/base_restore_dependencies.py similarity index 93% rename from cycode/cli/files_collector/sca/maven/base_restore_maven_dependencies.py rename to cycode/cli/files_collector/sca/base_restore_dependencies.py index bd4c3215..78ba3fd4 100644 --- a/cycode/cli/files_collector/sca/maven/base_restore_maven_dependencies.py +++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py @@ -4,7 +4,7 @@ import click from cycode.cli.models import Document -from cycode.cli.utils.path_utils import get_file_content, get_file_dir, join_paths +from cycode.cli.utils.path_utils import get_file_content, get_file_dir, get_path_from_context, join_paths from cycode.cli.utils.shell_executor import shell from cycode.cyclient import logger @@ -23,7 +23,7 @@ def execute_command(command: List[str], file_name: str, command_timeout: int) -> return dependencies -class BaseRestoreMavenDependencies(ABC): +class BaseRestoreDependencies(ABC): def __init__(self, context: click.Context, is_git_diff: bool, command_timeout: int) -> None: self.context = context self.is_git_diff = is_git_diff @@ -34,7 +34,7 @@ def restore(self, document: Document) -> Optional[Document]: def get_manifest_file_path(self, document: Document) -> str: return ( - join_paths(self.context.params['paths'][0], document.path) + join_paths(get_path_from_context(self.context), document.path) if self.context.obj.get('monitor') else document.path ) diff --git a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py index 21fdb7c3..d4896e95 100644 --- a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py @@ -3,7 +3,7 @@ import click -from cycode.cli.files_collector.sca.maven.base_restore_maven_dependencies import BaseRestoreMavenDependencies +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies from cycode.cli.models import Document BUILD_GRADLE_FILE_NAME = 'build.gradle' @@ -11,7 +11,7 @@ BUILD_GRADLE_DEP_TREE_FILE_NAME = 'gradle-dependencies-generated.txt' -class RestoreGradleDependencies(BaseRestoreMavenDependencies): +class RestoreGradleDependencies(BaseRestoreDependencies): def __init__(self, context: click.Context, is_git_diff: bool, command_timeout: int) -> None: super().__init__(context, is_git_diff, command_timeout) diff --git a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py index d9c117e6..084dec6c 100644 --- a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py @@ -3,8 +3,8 @@ import click -from cycode.cli.files_collector.sca.maven.base_restore_maven_dependencies import ( - BaseRestoreMavenDependencies, +from cycode.cli.files_collector.sca.base_restore_dependencies import ( + BaseRestoreDependencies, build_dep_tree_path, execute_command, ) @@ -16,7 +16,7 @@ MAVEN_DEP_TREE_FILE_NAME = 'bcde.mvndeps' -class RestoreMavenDependencies(BaseRestoreMavenDependencies): +class RestoreMavenDependencies(BaseRestoreDependencies): def __init__(self, context: click.Context, is_git_diff: bool, command_timeout: int) -> None: super().__init__(context, is_git_diff, command_timeout) diff --git a/cycode/cli/files_collector/sca/nuget/__init__.py b/cycode/cli/files_collector/sca/nuget/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py b/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py new file mode 100644 index 00000000..c54c3e5e --- /dev/null +++ b/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py @@ -0,0 +1,27 @@ +import os +from typing import List + +import click + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies +from cycode.cli.models import Document + +NUGET_PROJECT_FILE_EXTENSIONS = ['.csproj', '.vbproj'] +NUGET_LOCK_FILE_NAME = 'packages.lock.json' + + +class RestoreNugetDependencies(BaseRestoreDependencies): + def __init__(self, context: click.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(context, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + return any(document.path.endswith(ext) for ext in NUGET_PROJECT_FILE_EXTENSIONS) + + def get_command(self, manifest_file_path: str) -> List[str]: + return ['dotnet', 'restore', manifest_file_path, '--use-lock-file', '--verbosity', 'quiet'] + + def get_lock_file_name(self) -> str: + return NUGET_LOCK_FILE_NAME + + def verify_restore_file_already_exist(self, restore_file_path: str) -> bool: + return os.path.isfile(restore_file_path) diff --git a/cycode/cli/files_collector/sca/sca_code_scanner.py b/cycode/cli/files_collector/sca/sca_code_scanner.py index 7366bcbe..d4cc0293 100644 --- a/cycode/cli/files_collector/sca/sca_code_scanner.py +++ b/cycode/cli/files_collector/sca/sca_code_scanner.py @@ -4,22 +4,20 @@ import click from cycode.cli import consts +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies from cycode.cli.files_collector.sca.maven.restore_gradle_dependencies import RestoreGradleDependencies from cycode.cli.files_collector.sca.maven.restore_maven_dependencies import RestoreMavenDependencies +from cycode.cli.files_collector.sca.nuget.restore_nuget_dependencies import RestoreNugetDependencies from cycode.cli.models import Document from cycode.cli.utils.git_proxy import git_proxy -from cycode.cli.utils.path_utils import get_file_content, get_file_dir, join_paths +from cycode.cli.utils.path_utils import get_file_content, get_file_dir, get_path_from_context, join_paths from cycode.cyclient import logger if TYPE_CHECKING: from git import Repo - from cycode.cli.files_collector.sca.maven.base_restore_maven_dependencies import BaseRestoreMavenDependencies - -BUILD_GRADLE_FILE_NAME = 'build.gradle' -BUILD_GRADLE_KTS_FILE_NAME = 'build.gradle.kts' -BUILD_GRADLE_DEP_TREE_FILE_NAME = 'gradle-dependencies-generated.txt' BUILD_GRADLE_DEP_TREE_TIMEOUT = 180 +BUILD_NUGET_DEP_TREE_TIMEOUT = 180 def perform_pre_commit_range_scan_actions( @@ -90,7 +88,7 @@ def get_project_file_ecosystem(document: Document) -> Optional[str]: def try_restore_dependencies( context: click.Context, documents_to_add: Dict[str, Document], - restore_dependencies: 'BaseRestoreMavenDependencies', + restore_dependencies: 'BaseRestoreDependencies', document: Document, ) -> None: if restore_dependencies.is_project(document): @@ -104,7 +102,9 @@ def try_restore_dependencies( restore_dependencies_document.content = '' else: is_monitor_action = context.obj['monitor'] - project_path = context.params['paths'][0] + + project_path = get_path_from_context(context) + manifest_file_path = get_manifest_file_path(document, is_monitor_action, project_path) logger.debug('Succeeded to generate dependencies tree on path: %s', manifest_file_path) @@ -127,10 +127,11 @@ def add_dependencies_tree_document( documents_to_scan.extend(list(documents_to_add.values())) -def restore_handlers(context: click.Context, is_git_diff: bool) -> List[RestoreGradleDependencies]: +def restore_handlers(context: click.Context, is_git_diff: bool) -> List[BaseRestoreDependencies]: return [ RestoreGradleDependencies(context, is_git_diff, BUILD_GRADLE_DEP_TREE_TIMEOUT), RestoreMavenDependencies(context, is_git_diff, BUILD_GRADLE_DEP_TREE_TIMEOUT), + RestoreNugetDependencies(context, is_git_diff, BUILD_NUGET_DEP_TREE_TIMEOUT), ] diff --git a/cycode/cli/utils/path_utils.py b/cycode/cli/utils/path_utils.py index 02b0fcc6..4f8be3f1 100644 --- a/cycode/cli/utils/path_utils.py +++ b/cycode/cli/utils/path_utils.py @@ -3,6 +3,7 @@ from functools import lru_cache from typing import AnyStr, List, Optional +import click from binaryornot.helpers import is_binary_string from cycode.cyclient import logger @@ -100,3 +101,10 @@ def concat_unique_id(filename: str, unique_id: str) -> str: filename = filename[len(os.sep) :] return os.path.join(unique_id, filename) + + +def get_path_from_context(context: click.Context) -> Optional[str]: + path = context.params.get('path') + if path is None and 'paths' in context.params: + path = context.params['paths'][0] + return path From a92f63800171c17d7cdab6f6519f3355e838195d Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Mon, 14 Oct 2024 13:29:45 +0200 Subject: [PATCH 117/257] CM-41176 - Add Python 3.13 support; drop Python 3.7 support (#255) --- .github/workflows/build_executable.yml | 2 +- .github/workflows/pre_release.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/ruff.yml | 2 +- .github/workflows/tests.yml | 2 +- .github/workflows/tests_full.yml | 7 +- CONTRIBUTING.md | 3 +- README.md | 7 +- cycode/cli/commands/scan/code_scanner.py | 4 +- cycode/cli/exceptions/custom_exceptions.py | 12 +- cycode/cli/files_collector/excluder.py | 5 +- cycode/cli/user_settings/base_file_manager.py | 3 +- cycode/cli/utils/git_proxy.py | 12 +- cycode/cli/utils/progress_bar.py | 21 +- cycode/cli/utils/shell_executor.py | 4 +- cycode/cli/utils/task_timer.py | 2 +- cycode/cyclient/scan_config_base.py | 6 +- poetry.lock | 571 +++++++++--------- pyproject.toml | 28 +- tests/cli/commands/scan/test_code_scanner.py | 2 +- 20 files changed, 348 insertions(+), 349 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 0849871e..4ff61810 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -71,7 +71,7 @@ jobs: if: steps.cached-poetry.outputs.cache-hit != 'true' uses: snok/install-poetry@v1 with: - version: 1.5.1 + version: 1.8.3 - name: Add Poetry to PATH run: echo "$HOME/.local/bin" >> $GITHUB_PATH diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index d070ec19..8909fca8 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -47,7 +47,7 @@ jobs: if: steps.cached-poetry.outputs.cache-hit != 'true' uses: snok/install-poetry@v1 with: - version: 1.5.1 + version: 1.8.3 - name: Add Poetry to PATH run: echo "$HOME/.local/bin" >> $GITHUB_PATH diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f38a085f..e89885e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,7 +46,7 @@ jobs: if: steps.cached-poetry.outputs.cache-hit != 'true' uses: snok/install-poetry@v1 with: - version: 1.5.1 + version: 1.8.3 - name: Add Poetry to PATH run: echo "$HOME/.local/bin" >> $GITHUB_PATH diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 29e43c2d..3a91d0f3 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -36,7 +36,7 @@ jobs: if: steps.cached-poetry.outputs.cache-hit != 'true' uses: snok/install-poetry@v1 with: - version: 1.5.1 + version: 1.8.3 - name: Add Poetry to PATH run: echo "$HOME/.local/bin" >> $GITHUB_PATH diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7e43c4e7..7c4a2b47 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -41,7 +41,7 @@ jobs: if: steps.cached-poetry.outputs.cache-hit != 'true' uses: snok/install-poetry@v1 with: - version: 1.5.1 + version: 1.8.3 - name: Add Poetry to PATH run: echo "$HOME/.local/bin" >> $GITHUB_PATH diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index e6b13632..8181b586 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: os: [ macos-latest, ubuntu-latest, windows-latest ] - python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ] + python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12", "3.13" ] runs-on: ${{matrix.os}} @@ -50,13 +50,13 @@ jobs: uses: actions/cache@v3 with: path: ~/.local - key: poetry-${{ matrix.os }}-1 # increment to reset cache + key: poetry-${{ matrix.os }}-${{ matrix.python-version }}-1 # increment to reset cache - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' uses: snok/install-poetry@v1 with: - version: 1.5.1 + version: 1.8.3 - name: Add Poetry to PATH run: echo "$HOME/.local/bin" >> $GITHUB_PATH @@ -65,6 +65,7 @@ jobs: run: poetry install - name: Run executable test + if: matrix.python-version != '3.13' # we will migrate pyinstaller to 3.13 later run: | poetry run pyinstaller pyinstaller.spec ./dist/cycode-cli version diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a3e324ca..56852c22 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,10 +4,9 @@ ## How to contribute to Cycode CLI -The minimum version of Python that we support is 3.7. +The minimum version of Python that we support is 3.8. We recommend using this version for local development. But it’s fine to use a higher version without using new features from these versions. -We prefer 3.8 because it comes with the support of Apple Silicon, and it is as low as possible. The project is under Poetry project management. To deal with it, you should install it on your system: diff --git a/README.md b/README.md index 32d8eb49..dc625720 100644 --- a/README.md +++ b/README.md @@ -52,12 +52,7 @@ This guide will guide you through both installation and usage. # Prerequisites -> [!WARNING] -> Python 3.7 end-of-life was on 2023-06-27. -> It is recommended to use Python 3.8 or later. -> We will drop support for Python 3.7 soon. - -- The Cycode CLI application requires Python version 3.7 or later. +- The Cycode CLI application requires Python version 3.8 or later. - Use the [`cycode auth` command](#using-the-auth-command) to authenticate to Cycode with the CLI - Alternatively, you can obtain a Cycode Client ID and Client Secret Key by following the steps detailed in the [Service Account Token](https://docs.cycode.com/reference/creating-a-service-account-access-token) and [Personal Access Token](https://docs.cycode.com/reference/creating-a-personal-access-token-1) pages, which contain details on obtaining these values. diff --git a/cycode/cli/commands/scan/code_scanner.py b/cycode/cli/commands/scan/code_scanner.py index 409d4c3f..4f9772e2 100644 --- a/cycode/cli/commands/scan/code_scanner.py +++ b/cycode/cli/commands/scan/code_scanner.py @@ -253,8 +253,7 @@ def scan_commit_range( progress_bar.set_section_length(ScanProgressBarSection.PREPARE_LOCAL_FILES, total_commits_count) - scanned_commits_count = 0 - for commit in repo.iter_commits(rev=commit_range): + for scanned_commits_count, commit in enumerate(repo.iter_commits(rev=commit_range)): if _does_reach_to_max_commits_to_scan_limit(commit_ids_to_scan, max_commits_count): logger.debug('Reached to max commits to scan count. Going to scan only %s last commits', max_commits_count) progress_bar.update(ScanProgressBarSection.PREPARE_LOCAL_FILES, total_commits_count - scanned_commits_count) @@ -284,7 +283,6 @@ def scan_commit_range( ) documents_to_scan.extend(exclude_irrelevant_documents_to_scan(scan_type, commit_documents_to_scan)) - scanned_commits_count += 1 logger.debug('List of commit ids to scan, %s', {'commit_ids': commit_ids_to_scan}) logger.debug('Starting to scan commit range (it may take a few minutes)') diff --git a/cycode/cli/exceptions/custom_exceptions.py b/cycode/cli/exceptions/custom_exceptions.py index 0b1b3608..40abed63 100644 --- a/cycode/cli/exceptions/custom_exceptions.py +++ b/cycode/cli/exceptions/custom_exceptions.py @@ -7,20 +7,16 @@ class CycodeError(Exception): """Base class for all custom exceptions""" -class RequestError(CycodeError): - ... +class RequestError(CycodeError): ... -class RequestTimeout(RequestError): - ... +class RequestTimeout(RequestError): ... -class RequestConnectionError(RequestError): - ... +class RequestConnectionError(RequestError): ... -class RequestSslError(RequestConnectionError): - ... +class RequestSslError(RequestConnectionError): ... class RequestHttpError(RequestError): diff --git a/cycode/cli/files_collector/excluder.py b/cycode/cli/files_collector/excluder.py index 1e0eab8a..b8cb7920 100644 --- a/cycode/cli/files_collector/excluder.py +++ b/cycode/cli/files_collector/excluder.py @@ -94,10 +94,7 @@ def _is_relevant_file_to_scan(scan_type: str, filename: str) -> bool: ) return False - if scan_type == consts.SCA_SCAN_TYPE and not _is_file_relevant_for_sca_scan(filename): - return False - - return True + return not (scan_type == consts.SCA_SCAN_TYPE and not _is_file_relevant_for_sca_scan(filename)) def _is_file_relevant_for_sca_scan(filename: str) -> bool: diff --git a/cycode/cli/user_settings/base_file_manager.py b/cycode/cli/user_settings/base_file_manager.py index 778b8d23..b7be273d 100644 --- a/cycode/cli/user_settings/base_file_manager.py +++ b/cycode/cli/user_settings/base_file_manager.py @@ -7,8 +7,7 @@ class BaseFileManager(ABC): @abstractmethod - def get_filename(self) -> str: - ... + def get_filename(self) -> str: ... def read_file(self) -> Dict[Hashable, Any]: return read_file(self.get_filename()) diff --git a/cycode/cli/utils/git_proxy.py b/cycode/cli/utils/git_proxy.py index 5d535657..c46d016b 100644 --- a/cycode/cli/utils/git_proxy.py +++ b/cycode/cli/utils/git_proxy.py @@ -25,20 +25,16 @@ class GitProxyError(Exception): class _AbstractGitProxy(ABC): @abstractmethod - def get_repo(self, path: Optional['PathLike'] = None, *args, **kwargs) -> 'Repo': - ... + def get_repo(self, path: Optional['PathLike'] = None, *args, **kwargs) -> 'Repo': ... @abstractmethod - def get_null_tree(self) -> object: - ... + def get_null_tree(self) -> object: ... @abstractmethod - def get_invalid_git_repository_error(self) -> Type[BaseException]: - ... + def get_invalid_git_repository_error(self) -> Type[BaseException]: ... @abstractmethod - def get_git_command_error(self) -> Type[BaseException]: - ... + def get_git_command_error(self) -> Type[BaseException]: ... class _DummyGitProxy(_AbstractGitProxy): diff --git a/cycode/cli/utils/progress_bar.py b/cycode/cli/utils/progress_bar.py index 3bb6514d..623222d7 100644 --- a/cycode/cli/utils/progress_bar.py +++ b/cycode/cli/utils/progress_bar.py @@ -92,32 +92,25 @@ def __init__(self, *args, **kwargs) -> None: pass @abstractmethod - def __enter__(self) -> 'BaseProgressBar': - ... + def __enter__(self) -> 'BaseProgressBar': ... @abstractmethod - def __exit__(self, *args, **kwargs) -> None: - ... + def __exit__(self, *args, **kwargs) -> None: ... @abstractmethod - def start(self) -> None: - ... + def start(self) -> None: ... @abstractmethod - def stop(self) -> None: - ... + def stop(self) -> None: ... @abstractmethod - def set_section_length(self, section: 'ProgressBarSection', length: int = 0) -> None: - ... + def set_section_length(self, section: 'ProgressBarSection', length: int = 0) -> None: ... @abstractmethod - def update(self, section: 'ProgressBarSection') -> None: - ... + def update(self, section: 'ProgressBarSection') -> None: ... @abstractmethod - def update_label(self, label: Optional[str] = None) -> None: - ... + def update_label(self, label: Optional[str] = None) -> None: ... class DummyProgressBar(BaseProgressBar): diff --git a/cycode/cli/utils/shell_executor.py b/cycode/cli/utils/shell_executor.py index 15cdba47..fb023451 100644 --- a/cycode/cli/utils/shell_executor.py +++ b/cycode/cli/utils/shell_executor.py @@ -14,10 +14,10 @@ def shell( logger.debug('Executing shell command: %s', command) try: - result = subprocess.run( + result = subprocess.run( # noqa: S603 command, timeout=timeout, - shell=execute_in_shell, # noqa: S603 + shell=execute_in_shell, check=True, capture_output=True, ) diff --git a/cycode/cli/utils/task_timer.py b/cycode/cli/utils/task_timer.py index 0179179d..29e65dc8 100644 --- a/cycode/cli/utils/task_timer.py +++ b/cycode/cli/utils/task_timer.py @@ -73,7 +73,7 @@ def __exit__( # catch the exception of interrupt_main before exiting # the with statement and throw timeout error instead - if exc_type == KeyboardInterrupt: + if exc_type is KeyboardInterrupt: raise TimeoutError(f'Task timed out after {self.timeout} seconds') def timeout_function(self) -> None: diff --git a/cycode/cyclient/scan_config_base.py b/cycode/cyclient/scan_config_base.py index 1e63673d..e0bdd7ef 100644 --- a/cycode/cyclient/scan_config_base.py +++ b/cycode/cyclient/scan_config_base.py @@ -5,8 +5,7 @@ class ScanConfigBase(ABC): @abstractmethod - def get_service_name(self, scan_type: str, should_use_scan_service: bool = False) -> str: - ... + def get_service_name(self, scan_type: str, should_use_scan_service: bool = False) -> str: ... @staticmethod def get_async_scan_type(scan_type: str) -> str: @@ -25,8 +24,7 @@ def get_async_entity_type(scan_type: str) -> str: return 'repository' @abstractmethod - def get_detections_prefix(self) -> str: - ... + def get_detections_prefix(self) -> str: ... class DevScanConfig(ScanConfigBase): diff --git a/poetry.lock b/poetry.lock index 6bdda7f4..a1d8c39f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -13,18 +13,22 @@ files = [ [[package]] name = "arrow" -version = "1.2.3" +version = "1.3.0" description = "Better dates & times for Python" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "arrow-1.2.3-py3-none-any.whl", hash = "sha256:5a49ab92e3b7b71d96cd6bfcc4df14efefc9dfa96ea19045815914a6ab6b1fe2"}, - {file = "arrow-1.2.3.tar.gz", hash = "sha256:3934b30ca1b9f292376d9db15b19446088d12ec58629bc3f0da28fd55fb633a1"}, + {file = "arrow-1.3.0-py3-none-any.whl", hash = "sha256:c728b120ebc00eb84e01882a6f5e7927a53960aa990ce7dd2b10f39005a67f80"}, + {file = "arrow-1.3.0.tar.gz", hash = "sha256:d4540617648cb5f895730f1ad8c82a65f2dad0166f57b75f3ca54759c4d67a85"}, ] [package.dependencies] python-dateutil = ">=2.7.0" -typing-extensions = {version = "*", markers = "python_version < \"3.8\""} +types-python-dateutil = ">=2.8.10" + +[package.extras] +doc = ["doc8", "sphinx (>=7.0.0)", "sphinx-autobuild", "sphinx-autodoc-typehints", "sphinx_rtd_theme (>=1.3.0)"] +test = ["dateparser (==1.*)", "pre-commit", "pytest", "pytest-cov", "pytest-mock", "pytz (==2021.1)", "simplejson (==3.*)"] [[package]] name = "binaryornot" @@ -42,13 +46,13 @@ chardet = ">=3.0.2" [[package]] name = "certifi" -version = "2024.7.4" +version = "2024.8.30" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, - {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, + {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, + {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, ] [[package]] @@ -64,101 +68,116 @@ files = [ [[package]] name = "charset-normalizer" -version = "3.3.2" +version = "3.4.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7.0" files = [ - {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, - {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"}, + {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"}, + {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"}, ] [[package]] @@ -174,7 +193,6 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} [[package]] name = "colorama" @@ -261,17 +279,16 @@ toml = ["tomli"] [[package]] name = "dunamai" -version = "1.18.1" +version = "1.21.2" description = "Dynamic version generation" optional = false -python-versions = ">=3.5,<4.0" +python-versions = ">=3.5" files = [ - {file = "dunamai-1.18.1-py3-none-any.whl", hash = "sha256:ee7b042f7a687fa04fc383258eb93bd819c7bd8aec62e0974f3c69747e5958f2"}, - {file = "dunamai-1.18.1.tar.gz", hash = "sha256:5e9a91e43d16bb56fa8fcddcf92fa31b2e1126e060c3dcc8d094d9b508061f9d"}, + {file = "dunamai-1.21.2-py3-none-any.whl", hash = "sha256:87db76405bf9366f9b4925ff5bb1db191a9a1bd9f9693f81c4d3abb8298be6f0"}, + {file = "dunamai-1.21.2.tar.gz", hash = "sha256:05827fb5f032f5596bfc944b23f613c147e676de118681f3bb1559533d8a65c4"}, ] [package.dependencies] -importlib-metadata = {version = ">=1.6.0", markers = "python_version < \"3.8\""} packaging = ">=20.9" [[package]] @@ -315,7 +332,6 @@ files = [ [package.dependencies] gitdb = ">=4.0.1,<5" -typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\""} [package.extras] doc = ["sphinx (==4.3.2)", "sphinx-autodoc-typehints", "sphinx-rtd-theme", "sphinxcontrib-applehelp (>=1.0.2,<=1.0.4)", "sphinxcontrib-devhelp (==1.0.2)", "sphinxcontrib-htmlhelp (>=2.0.0,<=2.0.1)", "sphinxcontrib-qthelp (==1.0.3)", "sphinxcontrib-serializinghtml (==1.1.5)"] @@ -323,34 +339,40 @@ test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit", [[package]] name = "idna" -version = "3.7" +version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false -python-versions = ">=3.5" +python-versions = ">=3.6" files = [ - {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, - {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, ] +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + [[package]] name = "importlib-metadata" -version = "6.7.0" +version = "8.5.0" description = "Read metadata from Python packages" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "importlib_metadata-6.7.0-py3-none-any.whl", hash = "sha256:cb52082e659e97afc5dac71e79de97d8681de3aa07ff18578330904a9d18e5b5"}, - {file = "importlib_metadata-6.7.0.tar.gz", hash = "sha256:1aaf550d4f73e5d6783e7acb77aec43d49da8017410afae93822cc9cca98c4d4"}, + {file = "importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b"}, + {file = "importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7"}, ] [package.dependencies] -typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} -zipp = ">=0.5" +zipp = ">=3.20" [package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] perf = ["ipython"] -testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] +test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["pytest-mypy"] [[package]] name = "iniconfig" @@ -379,22 +401,21 @@ altgraph = ">=0.17" [[package]] name = "marshmallow" -version = "3.19.0" +version = "3.22.0" description = "A lightweight library for converting complex datatypes to and from native Python datatypes." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "marshmallow-3.19.0-py3-none-any.whl", hash = "sha256:93f0958568da045b0021ec6aeb7ac37c81bfcccbb9a0e7ed8559885070b3a19b"}, - {file = "marshmallow-3.19.0.tar.gz", hash = "sha256:90032c0fd650ce94b6ec6dc8dfeb0e3ff50c144586462c389b81a07205bedb78"}, + {file = "marshmallow-3.22.0-py3-none-any.whl", hash = "sha256:71a2dce49ef901c3f97ed296ae5051135fd3febd2bf43afe0ae9a82143a494d9"}, + {file = "marshmallow-3.22.0.tar.gz", hash = "sha256:4972f529104a220bb8637d595aa4c9762afbe7f7a77d82dc58c1615d70c5823e"}, ] [package.dependencies] packaging = ">=17.0" [package.extras] -dev = ["flake8 (==5.0.4)", "flake8-bugbear (==22.10.25)", "mypy (==0.990)", "pre-commit (>=2.4,<3.0)", "pytest", "pytz", "simplejson", "tox"] -docs = ["alabaster (==0.7.12)", "autodocsumm (==0.2.9)", "sphinx (==5.3.0)", "sphinx-issues (==3.0.1)", "sphinx-version-warning (==1.1.2)"] -lint = ["flake8 (==5.0.4)", "flake8-bugbear (==22.10.25)", "mypy (==0.990)", "pre-commit (>=2.4,<3.0)"] +dev = ["marshmallow[tests]", "pre-commit (>=3.5,<4.0)", "tox"] +docs = ["alabaster (==1.0.0)", "autodocsumm (==0.2.13)", "sphinx (==8.0.2)", "sphinx-issues (==4.1.0)", "sphinx-version-warning (==1.1.2)"] tests = ["pytest", "pytz", "simplejson"] [[package]] @@ -415,51 +436,48 @@ test = ["pytest (<5.4)", "pytest-cov"] [[package]] name = "packaging" -version = "24.0" +version = "24.1" description = "Core utilities for Python packages" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, - {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] [[package]] name = "pathspec" -version = "0.11.2" +version = "0.12.1" description = "Utility library for gitignore style pattern matching of file paths." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"}, - {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, ] [[package]] name = "pefile" -version = "2023.2.7" +version = "2024.8.26" description = "Python PE parsing module" optional = false python-versions = ">=3.6.0" files = [ - {file = "pefile-2023.2.7-py3-none-any.whl", hash = "sha256:da185cd2af68c08a6cd4481f7325ed600a88f6a813bad9dea07ab3ef73d8d8d6"}, - {file = "pefile-2023.2.7.tar.gz", hash = "sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc"}, + {file = "pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f"}, + {file = "pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632"}, ] [[package]] name = "pluggy" -version = "1.2.0" +version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849"}, - {file = "pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3"}, + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, ] -[package.dependencies] -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} - [package.extras] dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] @@ -487,7 +505,6 @@ files = [ [package.dependencies] altgraph = "*" -importlib-metadata = {version = ">=1.4", markers = "python_version < \"3.8\""} macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""} pefile = {version = ">=2022.5.30", markers = "sys_platform == \"win32\""} pyinstaller-hooks-contrib = ">=2021.4" @@ -500,13 +517,13 @@ hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"] [[package]] name = "pyinstaller-hooks-contrib" -version = "2024.7" +version = "2024.8" description = "Community maintained hooks for PyInstaller" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pyinstaller_hooks_contrib-2024.7-py2.py3-none-any.whl", hash = "sha256:8bf0775771fbaf96bcd2f4dfd6f7ae6c1dd1b1efe254c7e50477b3c08e7841d8"}, - {file = "pyinstaller_hooks_contrib-2024.7.tar.gz", hash = "sha256:fd5f37dcf99bece184e40642af88be16a9b89613ecb958a8bd1136634fc9fac5"}, + {file = "pyinstaller_hooks_contrib-2024.8-py3-none-any.whl", hash = "sha256:0057fe9a5c398d3f580e73e58793a1d4a8315ca91c3df01efea1c14ed557825a"}, + {file = "pyinstaller_hooks_contrib-2024.8.tar.gz", hash = "sha256:29b68d878ab739e967055b56a93eb9b58e529d5b054fbab7a2f2bacf80cef3e2"}, ] [package.dependencies] @@ -516,22 +533,19 @@ setuptools = ">=42.0.0" [[package]] name = "pyjwt" -version = "2.8.0" +version = "2.9.0" description = "JSON Web Token implementation in Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "PyJWT-2.8.0-py3-none-any.whl", hash = "sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320"}, - {file = "PyJWT-2.8.0.tar.gz", hash = "sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de"}, + {file = "PyJWT-2.9.0-py3-none-any.whl", hash = "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850"}, + {file = "pyjwt-2.9.0.tar.gz", hash = "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c"}, ] -[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"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] [[package]] @@ -548,7 +562,6 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} iniconfig = "*" packaging = "*" pluggy = ">=0.12,<2.0" @@ -590,84 +603,86 @@ six = ">=1.5" [[package]] name = "pywin32-ctypes" -version = "0.2.2" +version = "0.2.3" description = "A (partial) reimplementation of pywin32 using ctypes/cffi" optional = false python-versions = ">=3.6" files = [ - {file = "pywin32-ctypes-0.2.2.tar.gz", hash = "sha256:3426e063bdd5fd4df74a14fa3cf80a0b42845a87e1d1e81f6549f9daec593a60"}, - {file = "pywin32_ctypes-0.2.2-py3-none-any.whl", hash = "sha256:bf490a1a709baf35d688fe0ecf980ed4de11d2b3e37b51e5442587a75d9957e7"}, + {file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"}, + {file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"}, ] [[package]] name = "pyyaml" -version = "6.0.1" +version = "6.0.2" description = "YAML parser and emitter for Python" optional = false -python-versions = ">=3.6" -files = [ - {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, - {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, - {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, - {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, - {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, - {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, - {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, - {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, - {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, - {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, - {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, - {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, - {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, - {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, - {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, - {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, - {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, - {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, - {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, - {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, +python-versions = ">=3.8" +files = [ + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, ] [[package]] name = "requests" -version = "2.31.0" +version = "2.32.3" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, - {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, ] [package.dependencies] @@ -695,7 +710,6 @@ files = [ pyyaml = "*" requests = ">=2.30.0,<3.0" types-PyYAML = "*" -typing-extensions = {version = "*", markers = "python_version < \"3.8\""} urllib3 = ">=1.25.10,<3.0" [package.extras] @@ -703,39 +717,40 @@ tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asy [[package]] name = "ruff" -version = "0.1.11" +version = "0.6.9" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.1.11-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a7f772696b4cdc0a3b2e527fc3c7ccc41cdcb98f5c80fdd4f2b8c50eb1458196"}, - {file = "ruff-0.1.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:934832f6ed9b34a7d5feea58972635c2039c7a3b434fe5ba2ce015064cb6e955"}, - {file = "ruff-0.1.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea0d3e950e394c4b332bcdd112aa566010a9f9c95814844a7468325290aabfd9"}, - {file = "ruff-0.1.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9bd4025b9c5b429a48280785a2b71d479798a69f5c2919e7d274c5f4b32c3607"}, - {file = "ruff-0.1.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1ad00662305dcb1e987f5ec214d31f7d6a062cae3e74c1cbccef15afd96611d"}, - {file = "ruff-0.1.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4b077ce83f47dd6bea1991af08b140e8b8339f0ba8cb9b7a484c30ebab18a23f"}, - {file = "ruff-0.1.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4a88efecec23c37b11076fe676e15c6cdb1271a38f2b415e381e87fe4517f18"}, - {file = "ruff-0.1.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b25093dad3b055667730a9b491129c42d45e11cdb7043b702e97125bcec48a1"}, - {file = "ruff-0.1.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:231d8fb11b2cc7c0366a326a66dafc6ad449d7fcdbc268497ee47e1334f66f77"}, - {file = "ruff-0.1.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:09c415716884950080921dd6237767e52e227e397e2008e2bed410117679975b"}, - {file = "ruff-0.1.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0f58948c6d212a6b8d41cd59e349751018797ce1727f961c2fa755ad6208ba45"}, - {file = "ruff-0.1.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:190a566c8f766c37074d99640cd9ca3da11d8deae2deae7c9505e68a4a30f740"}, - {file = "ruff-0.1.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6464289bd67b2344d2a5d9158d5eb81025258f169e69a46b741b396ffb0cda95"}, - {file = "ruff-0.1.11-py3-none-win32.whl", hash = "sha256:9b8f397902f92bc2e70fb6bebfa2139008dc72ae5177e66c383fa5426cb0bf2c"}, - {file = "ruff-0.1.11-py3-none-win_amd64.whl", hash = "sha256:eb85ee287b11f901037a6683b2374bb0ec82928c5cbc984f575d0437979c521a"}, - {file = "ruff-0.1.11-py3-none-win_arm64.whl", hash = "sha256:97ce4d752f964ba559c7023a86e5f8e97f026d511e48013987623915431c7ea9"}, - {file = "ruff-0.1.11.tar.gz", hash = "sha256:f9d4d88cb6eeb4dfe20f9f0519bd2eaba8119bde87c3d5065c541dbae2b5a2cb"}, + {file = "ruff-0.6.9-py3-none-linux_armv6l.whl", hash = "sha256:064df58d84ccc0ac0fcd63bc3090b251d90e2a372558c0f057c3f75ed73e1ccd"}, + {file = "ruff-0.6.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:140d4b5c9f5fc7a7b074908a78ab8d384dd7f6510402267bc76c37195c02a7ec"}, + {file = "ruff-0.6.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53fd8ca5e82bdee8da7f506d7b03a261f24cd43d090ea9db9a1dc59d9313914c"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645d7d8761f915e48a00d4ecc3686969761df69fb561dd914a773c1a8266e14e"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eae02b700763e3847595b9d2891488989cac00214da7f845f4bcf2989007d577"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d5ccc9e58112441de8ad4b29dcb7a86dc25c5f770e3c06a9d57e0e5eba48829"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:417b81aa1c9b60b2f8edc463c58363075412866ae4e2b9ab0f690dc1e87ac1b5"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c866b631f5fbce896a74a6e4383407ba7507b815ccc52bcedabb6810fdb3ef7"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b118afbb3202f5911486ad52da86d1d52305b59e7ef2031cea3425142b97d6f"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a67267654edc23c97335586774790cde402fb6bbdb3c2314f1fc087dee320bfa"}, + {file = "ruff-0.6.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3ef0cc774b00fec123f635ce5c547dac263f6ee9fb9cc83437c5904183b55ceb"}, + {file = "ruff-0.6.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:12edd2af0c60fa61ff31cefb90aef4288ac4d372b4962c2864aeea3a1a2460c0"}, + {file = "ruff-0.6.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:55bb01caeaf3a60b2b2bba07308a02fca6ab56233302406ed5245180a05c5625"}, + {file = "ruff-0.6.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:925d26471fa24b0ce5a6cdfab1bb526fb4159952385f386bdcc643813d472039"}, + {file = "ruff-0.6.9-py3-none-win32.whl", hash = "sha256:eb61ec9bdb2506cffd492e05ac40e5bc6284873aceb605503d8494180d6fc84d"}, + {file = "ruff-0.6.9-py3-none-win_amd64.whl", hash = "sha256:785d31851c1ae91f45b3d8fe23b8ae4b5170089021fbb42402d811135f0b7117"}, + {file = "ruff-0.6.9-py3-none-win_arm64.whl", hash = "sha256:a9641e31476d601f83cd602608739a0840e348bda93fec9f1ee816f8b6798b93"}, + {file = "ruff-0.6.9.tar.gz", hash = "sha256:b076ef717a8e5bc819514ee1d602bbdca5b4420ae13a9cf61a0c0a4f53a2baa2"}, ] [[package]] name = "sentry-sdk" -version = "2.10.0" +version = "2.16.0" description = "Python client for Sentry (https://sentry.io)" optional = false python-versions = ">=3.6" files = [ - {file = "sentry_sdk-2.10.0-py2.py3-none-any.whl", hash = "sha256:87b3d413c87d8e7f816cc9334bff255a83d8b577db2b22042651c30c19c09190"}, - {file = "sentry_sdk-2.10.0.tar.gz", hash = "sha256:545fcc6e36c335faa6d6cda84669b6e17025f31efbf3b2211ec14efe008b75d1"}, + {file = "sentry_sdk-2.16.0-py2.py3-none-any.whl", hash = "sha256:49139c31ebcd398f4f6396b18910610a0c1602f6e67083240c33019d1f6aa30c"}, + {file = "sentry_sdk-2.16.0.tar.gz", hash = "sha256:90f733b32e15dfc1999e6b7aca67a38688a567329de4d6e184154a73f96c6892"}, ] [package.dependencies] @@ -758,14 +773,16 @@ 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)"] +http2 = ["httpcore[http2] (==1.*)"] httpx = ["httpx (>=0.16.0)"] huey = ["huey (>=2)"] huggingface-hub = ["huggingface-hub (>=0.22)"] langchain = ["langchain (>=0.0.210)"] +litestar = ["litestar (>=2.0.0)"] 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)"] +opentelemetry-experimental = ["opentelemetry-distro"] pure-eval = ["asttokens", "executing", "pure-eval"] pymongo = ["pymongo (>=3.1)"] pyspark = ["pyspark (>=2.4.4)"] @@ -779,19 +796,23 @@ tornado = ["tornado (>=6)"] [[package]] name = "setuptools" -version = "68.0.0" +version = "75.1.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "setuptools-68.0.0-py3-none-any.whl", hash = "sha256:11e52c67415a381d10d6b462ced9cfb97066179f0e871399e006c4ab101fc85f"}, - {file = "setuptools-68.0.0.tar.gz", hash = "sha256:baf1fdb41c6da4cd2eae722e135500da913332ab3f2f5c7d33af9b492acb5235"}, + {file = "setuptools-75.1.0-py3-none-any.whl", hash = "sha256:35ab7fd3bcd95e6b7fd704e4a1539513edad446c097797f2985e0e4b960772f2"}, + {file = "setuptools-75.1.0.tar.gz", hash = "sha256:d59a21b17a275fb872a9c3dae73963160ae079f1049ed956880cd7c09b120538"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"] +core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.11.*)", "pytest-mypy"] [[package]] name = "six" @@ -828,35 +849,35 @@ files = [ [[package]] name = "tomli" -version = "2.0.1" +version = "2.0.2" description = "A lil' TOML parser" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, + {file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"}, + {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"}, ] [[package]] -name = "types-pyyaml" -version = "6.0.12.12" -description = "Typing stubs for PyYAML" +name = "types-python-dateutil" +version = "2.9.0.20241003" +description = "Typing stubs for python-dateutil" optional = false -python-versions = "*" +python-versions = ">=3.8" files = [ - {file = "types-PyYAML-6.0.12.12.tar.gz", hash = "sha256:334373d392fde0fdf95af5c3f1661885fa10c52167b14593eb856289e1855062"}, - {file = "types_PyYAML-6.0.12.12-py3-none-any.whl", hash = "sha256:c05bc6c158facb0676674b7f11fe3960db4f389718e19e62bd2b84d6205cfd24"}, + {file = "types-python-dateutil-2.9.0.20241003.tar.gz", hash = "sha256:58cb85449b2a56d6684e41aeefb4c4280631246a0da1a719bdbe6f3fb0317446"}, + {file = "types_python_dateutil-2.9.0.20241003-py3-none-any.whl", hash = "sha256:250e1d8e80e7bbc3a6c99b907762711d1a1cdd00e978ad39cb5940f6f0a87f3d"}, ] [[package]] -name = "typing-extensions" -version = "4.7.1" -description = "Backported and Experimental Type Hints for Python 3.7+" +name = "types-pyyaml" +version = "6.0.12.20240917" +description = "Typing stubs for PyYAML" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, - {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, + {file = "types-PyYAML-6.0.12.20240917.tar.gz", hash = "sha256:d1405a86f9576682234ef83bcb4e6fff7c9305c8b1fbad5e0bcd4f7dbdc9c587"}, + {file = "types_PyYAML-6.0.12.20240917-py3-none-any.whl", hash = "sha256:392b267f1c0fe6022952462bf5d6523f31e37f6cea49b14cee7ad634b6301570"}, ] [[package]] @@ -877,20 +898,24 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "zipp" -version = "3.15.0" +version = "3.20.2" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, - {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, + {file = "zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350"}, + {file = "zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy"] [metadata] lock-version = "2.0" -python-versions = ">=3.7,<3.13" -content-hash = "02e26455e3dc3f405b006d9257940e98a70ce9a0bce7324d570652df8d452427" +python-versions = ">=3.8,<3.14" +content-hash = "6e23c9650b529e0c928f90a17d549d73b8418e11a86c2a1c9213f7582faa7e17" diff --git a/pyproject.toml b/pyproject.toml index e8c85eba..9c1e1f9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,29 +15,29 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ] [tool.poetry.scripts] cycode = "cycode.cli.main:main_cli" [tool.poetry.dependencies] -python = ">=3.7,<3.13" +python = ">=3.8,<3.14" click = ">=8.1.0,<8.2.0" colorama = ">=0.4.3,<0.5.0" pyyaml = ">=6.0,<7.0" -marshmallow = ">=3.15.0,<3.21.0" -pathspec = ">=0.11.1,<0.12.0" +marshmallow = ">=3.15.0,<3.23.0" # 3.23 dropped support for Python 3.8 +pathspec = ">=0.11.1,<0.13.0" gitpython = ">=3.1.30,<3.2.0" -arrow = ">=1.0.0,<1.3.0" +arrow = ">=1.0.0,<1.4.0" binaryornot = ">=0.4.4,<0.5.0" texttable = ">=1.6.7,<1.8.0" -requests = ">=2.24,<3.0" +requests = ">=2.32.2,<3.0" urllib3 = "1.26.19" # 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" @@ -50,11 +50,11 @@ coverage = ">=7.2.3,<7.3.0" responses = ">=0.23.1,<0.24.0" [tool.poetry.group.executable.dependencies] -pyinstaller = ">=5.13.2,<5.14.0" -dunamai = ">=1.18.0,<1.19.0" +pyinstaller = {version=">=5.13.2,<5.14.0", python=">=3.8,<3.13"} +dunamai = ">=1.18.0,<1.22.0" [tool.poetry.group.dev.dependencies] -ruff = "0.1.11" +ruff = "0.6.9" [tool.pytest.ini_options] log_cli = true @@ -70,6 +70,10 @@ vcs = "git" style = "pep440" [tool.ruff] +line-length = 120 +target-version = "py38" + +[tool.ruff.lint] extend-select = [ "E", # pycodestyle errors "W", # pycodestyle warnings @@ -100,8 +104,6 @@ extend-select = [ "YTT", "G", ] -line-length = 120 -target-version = "py37" ignore = [ "ANN002", # Missing type annotation for `*args` "ANN003", # Missing type annotation for `**kwargs` @@ -111,7 +113,7 @@ ignore = [ "ISC001", # Conflicts with ruff format ] -[tool.ruff.flake8-quotes] +[tool.ruff.lint.flake8-quotes] docstring-quotes = "double" multiline-quotes = "double" inline-quotes = "single" @@ -119,7 +121,7 @@ inline-quotes = "single" [tool.ruff.lint.flake8-tidy-imports] ban-relative-imports = "all" -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] "tests/*.py" = ["S101", "S105"] "cycode/*.py" = ["BLE001"] diff --git a/tests/cli/commands/scan/test_code_scanner.py b/tests/cli/commands/scan/test_code_scanner.py index c993958c..7b739a82 100644 --- a/tests/cli/commands/scan/test_code_scanner.py +++ b/tests/cli/commands/scan/test_code_scanner.py @@ -69,6 +69,6 @@ def test_generate_document() -> None: generated_tfplan_document = _generate_document(path, consts.INFRA_CONFIGURATION_SCAN_TYPE, content, is_git_diff) - assert type(generated_tfplan_document) == Document + assert isinstance(generated_tfplan_document, Document) assert generated_tfplan_document.path.endswith('.tf') assert generated_tfplan_document.is_git_diff_format == is_git_diff From f6c6543a9550d1be2fcc0495a35a0e36b316b945 Mon Sep 17 00:00:00 2001 From: naftalicy Date: Mon, 21 Oct 2024 17:30:30 +0300 Subject: [PATCH 118/257] CM-40907 - Implement NPM restore support for SCA (#254) --- .../sca/base_restore_dependencies.py | 21 +++++++--- .../sca/maven/restore_gradle_dependencies.py | 2 +- .../sca/maven/restore_maven_dependencies.py | 3 +- .../cli/files_collector/sca/npm/__init__.py | 0 .../sca/npm/restore_npm_dependencies.py | 39 +++++++++++++++++++ .../files_collector/sca/sca_code_scanner.py | 3 ++ cycode/cli/utils/shell_executor.py | 10 +---- 7 files changed, 63 insertions(+), 15 deletions(-) create mode 100644 cycode/cli/files_collector/sca/npm/__init__.py create mode 100644 cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py diff --git a/cycode/cli/files_collector/sca/base_restore_dependencies.py b/cycode/cli/files_collector/sca/base_restore_dependencies.py index 78ba3fd4..c64b0720 100644 --- a/cycode/cli/files_collector/sca/base_restore_dependencies.py +++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py @@ -13,9 +13,15 @@ def build_dep_tree_path(path: str, generated_file_name: str) -> str: return join_paths(get_file_dir(path), generated_file_name) -def execute_command(command: List[str], file_name: str, command_timeout: int) -> Optional[str]: +def execute_command( + command: List[str], file_name: str, command_timeout: int, dependencies_file_name: Optional[str] = None +) -> Optional[str]: try: - dependencies = shell(command, command_timeout) + dependencies = shell(command=command, timeout=command_timeout) + # Write stdout output to the file if output_file_path is provided + if dependencies_file_name: + with open(dependencies_file_name, 'w') as output_file: + output_file.write(dependencies) except Exception as e: logger.debug('Failed to restore dependencies via shell command, %s', {'filename': file_name}, exc_info=e) return None @@ -24,10 +30,13 @@ def execute_command(command: List[str], file_name: str, command_timeout: int) -> class BaseRestoreDependencies(ABC): - def __init__(self, context: click.Context, is_git_diff: bool, command_timeout: int) -> None: + def __init__( + self, context: click.Context, is_git_diff: bool, command_timeout: int, create_output_file_manually: bool = False + ) -> None: self.context = context self.is_git_diff = is_git_diff self.command_timeout = command_timeout + self.create_output_file_manually = create_output_file_manually def restore(self, document: Document) -> Optional[Document]: return self.try_restore_dependencies(document) @@ -46,9 +55,11 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: if self.verify_restore_file_already_exist(restore_file_path): restore_file_content = get_file_content(restore_file_path) else: - restore_file_content = execute_command( - self.get_command(manifest_file_path), manifest_file_path, self.command_timeout + output_file_path = restore_file_path if self.create_output_file_manually else None + execute_command( + self.get_command(manifest_file_path), manifest_file_path, self.command_timeout, output_file_path ) + restore_file_content = get_file_content(restore_file_path) return Document(restore_file_path, restore_file_content, self.is_git_diff) diff --git a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py index d4896e95..f925c28e 100644 --- a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py @@ -13,7 +13,7 @@ class RestoreGradleDependencies(BaseRestoreDependencies): def __init__(self, context: click.Context, is_git_diff: bool, command_timeout: int) -> None: - super().__init__(context, is_git_diff, command_timeout) + super().__init__(context, is_git_diff, command_timeout, create_output_file_manually=True) def is_project(self, document: Document) -> bool: return document.path.endswith(BUILD_GRADLE_FILE_NAME) or document.path.endswith(BUILD_GRADLE_KTS_FILE_NAME) diff --git a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py index 084dec6c..84732021 100644 --- a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py @@ -1,3 +1,4 @@ +import os from os import path from typing import List, Optional @@ -30,7 +31,7 @@ def get_lock_file_name(self) -> str: return join_paths('target', MAVEN_CYCLONE_DEP_TREE_FILE_NAME) def verify_restore_file_already_exist(self, restore_file_path: str) -> bool: - return False + return os.path.isfile(restore_file_path) def try_restore_dependencies(self, document: Document) -> Optional[Document]: restore_dependencies_document = super().try_restore_dependencies(document) diff --git a/cycode/cli/files_collector/sca/npm/__init__.py b/cycode/cli/files_collector/sca/npm/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py new file mode 100644 index 00000000..ea6f4061 --- /dev/null +++ b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py @@ -0,0 +1,39 @@ +import os +from typing import List + +import click + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies +from cycode.cli.models import Document + +NPM_PROJECT_FILE_EXTENSIONS = ['.json'] +NPM_LOCK_FILE_NAME = 'package-lock.json' +NPM_MANIFEST_FILE_NAME = 'package.json' + + +class RestoreNpmDependencies(BaseRestoreDependencies): + def __init__(self, context: click.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(context, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + return any(document.path.endswith(ext) for ext in NPM_PROJECT_FILE_EXTENSIONS) + + def get_command(self, manifest_file_path: str) -> List[str]: + return [ + 'npm', + 'install', + '--prefix', + self.prepare_manifest_file_path_for_command(manifest_file_path), + '--package-lock-only', + '--ignore-scripts', + '--no-audit', + ] + + def get_lock_file_name(self) -> str: + return NPM_LOCK_FILE_NAME + + def verify_restore_file_already_exist(self, restore_file_path: str) -> bool: + return os.path.isfile(restore_file_path) + + def prepare_manifest_file_path_for_command(self, manifest_file_path: str) -> str: + return manifest_file_path.replace(os.sep + NPM_MANIFEST_FILE_NAME, '') diff --git a/cycode/cli/files_collector/sca/sca_code_scanner.py b/cycode/cli/files_collector/sca/sca_code_scanner.py index d4cc0293..c785be87 100644 --- a/cycode/cli/files_collector/sca/sca_code_scanner.py +++ b/cycode/cli/files_collector/sca/sca_code_scanner.py @@ -7,6 +7,7 @@ from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies from cycode.cli.files_collector.sca.maven.restore_gradle_dependencies import RestoreGradleDependencies from cycode.cli.files_collector.sca.maven.restore_maven_dependencies import RestoreMavenDependencies +from cycode.cli.files_collector.sca.npm.restore_npm_dependencies import RestoreNpmDependencies from cycode.cli.files_collector.sca.nuget.restore_nuget_dependencies import RestoreNugetDependencies from cycode.cli.models import Document from cycode.cli.utils.git_proxy import git_proxy @@ -18,6 +19,7 @@ BUILD_GRADLE_DEP_TREE_TIMEOUT = 180 BUILD_NUGET_DEP_TREE_TIMEOUT = 180 +BUILD_NPM_DEP_TREE_TIMEOUT = 180 def perform_pre_commit_range_scan_actions( @@ -132,6 +134,7 @@ def restore_handlers(context: click.Context, is_git_diff: bool) -> List[BaseRest RestoreGradleDependencies(context, is_git_diff, BUILD_GRADLE_DEP_TREE_TIMEOUT), RestoreMavenDependencies(context, is_git_diff, BUILD_GRADLE_DEP_TREE_TIMEOUT), RestoreNugetDependencies(context, is_git_diff, BUILD_NUGET_DEP_TREE_TIMEOUT), + RestoreNpmDependencies(context, is_git_diff, BUILD_NPM_DEP_TREE_TIMEOUT), ] diff --git a/cycode/cli/utils/shell_executor.py b/cycode/cli/utils/shell_executor.py index fb023451..a0883d6d 100644 --- a/cycode/cli/utils/shell_executor.py +++ b/cycode/cli/utils/shell_executor.py @@ -8,18 +8,12 @@ _SUBPROCESS_DEFAULT_TIMEOUT_SEC = 60 -def shell( - command: Union[str, List[str]], timeout: int = _SUBPROCESS_DEFAULT_TIMEOUT_SEC, execute_in_shell: bool = False -) -> Optional[str]: +def shell(command: Union[str, List[str]], timeout: int = _SUBPROCESS_DEFAULT_TIMEOUT_SEC) -> Optional[str]: logger.debug('Executing shell command: %s', command) try: result = subprocess.run( # noqa: S603 - command, - timeout=timeout, - shell=execute_in_shell, - check=True, - capture_output=True, + command, timeout=timeout, check=True, capture_output=True ) return result.stdout.decode('UTF-8').strip() From 68ad6c89338dae995afcf0a1e474de0c216e27fa Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Mon, 21 Oct 2024 16:33:39 +0200 Subject: [PATCH 119/257] CM-41056 - Add support for Swift Package Manager (#257) --- cycode/cli/consts.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 65db60c4..a215bf9a 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -48,7 +48,7 @@ '.model', ) -SCA_CONFIGURATION_SCAN_SUPPORTED_FILES = ( +SCA_CONFIGURATION_SCAN_SUPPORTED_FILES = ( # keep in lowercase 'cargo.lock', 'cargo.toml', 'composer.json', @@ -82,6 +82,8 @@ 'setup.py', 'mix.exs', 'mix.lock', + 'package.swift', + 'package.resolved', ) SCA_EXCLUDED_PATHS = ('node_modules',) @@ -101,6 +103,7 @@ 'pypi_requirements': ['requirements.txt'], 'pypi_setup': ['setup.py'], 'hex': ['mix.exs', 'mix.lock'], + 'swift_pm': ['Package.swift', 'Package.resolved'], } COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES = [SECRET_SCAN_TYPE, SCA_SCAN_TYPE] From a4010a993c0e00e33bdccf0fe9e12fce2ed7fc76 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 22 Oct 2024 12:26:06 +0200 Subject: [PATCH 120/257] CM-41380 - Fix SBOM report creation (#258) --- CONTRIBUTING.md | 4 +- .../files_collector/sca/sca_code_scanner.py | 43 ++++++++++--------- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 56852c22..a95c8c28 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,7 +53,7 @@ python cycode/cli/main.py ### Code linting and formatting -We use `ruff` and `ruff format`. +We use `ruff`. It is configured well, so you don’t need to do anything. You can see all enabled rules in the `pyproject.toml` file. Both tests and the main codebase are checked. @@ -63,7 +63,7 @@ GitHub Actions will check that your code is formatted well. You can run it local ```shell # lint -poetry run ruff . +poetry run ruff check . # format poetry run ruff format . ``` diff --git a/cycode/cli/files_collector/sca/sca_code_scanner.py b/cycode/cli/files_collector/sca/sca_code_scanner.py index c785be87..1090e7bf 100644 --- a/cycode/cli/files_collector/sca/sca_code_scanner.py +++ b/cycode/cli/files_collector/sca/sca_code_scanner.py @@ -93,27 +93,28 @@ def try_restore_dependencies( restore_dependencies: 'BaseRestoreDependencies', document: Document, ) -> None: - if restore_dependencies.is_project(document): - restore_dependencies_document = restore_dependencies.restore(document) - if restore_dependencies_document is None: - logger.warning('Error occurred while trying to generate dependencies tree, %s', {'filename': document.path}) - return - - if restore_dependencies_document.content is None: - logger.warning('Error occurred while trying to generate dependencies tree, %s', {'filename': document.path}) - restore_dependencies_document.content = '' - else: - is_monitor_action = context.obj['monitor'] - - project_path = get_path_from_context(context) - - manifest_file_path = get_manifest_file_path(document, is_monitor_action, project_path) - logger.debug('Succeeded to generate dependencies tree on path: %s', manifest_file_path) - - if restore_dependencies_document.path in documents_to_add: - logger.debug('Duplicate document on restore for path: %s', restore_dependencies_document.path) - else: - documents_to_add[restore_dependencies_document.path] = restore_dependencies_document + if not restore_dependencies.is_project(document): + return + + restore_dependencies_document = restore_dependencies.restore(document) + if restore_dependencies_document is None: + logger.warning('Error occurred while trying to generate dependencies tree, %s', {'filename': document.path}) + return + + if restore_dependencies_document.content is None: + logger.warning('Error occurred while trying to generate dependencies tree, %s', {'filename': document.path}) + restore_dependencies_document.content = '' + else: + is_monitor_action = context.obj.get('monitor', False) + project_path = get_path_from_context(context) + + manifest_file_path = get_manifest_file_path(document, is_monitor_action, project_path) + logger.debug('Succeeded to generate dependencies tree on path: %s', manifest_file_path) + + if restore_dependencies_document.path in documents_to_add: + logger.debug('Duplicate document on restore for path: %s', restore_dependencies_document.path) + else: + documents_to_add[restore_dependencies_document.path] = restore_dependencies_document def add_dependencies_tree_document( From 590642594d29bfdfdb75173611e24f7e1c351b69 Mon Sep 17 00:00:00 2001 From: naftalicy Date: Tue, 22 Oct 2024 15:58:33 +0300 Subject: [PATCH 121/257] CM-40909 - Implement Go restore support for SCA (#256) --- cycode/cli/files_collector/sca/go/__init__.py | 0 .../sca/go/restore_go_dependencies.py | 31 +++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 cycode/cli/files_collector/sca/go/__init__.py create mode 100644 cycode/cli/files_collector/sca/go/restore_go_dependencies.py diff --git a/cycode/cli/files_collector/sca/go/__init__.py b/cycode/cli/files_collector/sca/go/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py new file mode 100644 index 00000000..512af8a5 --- /dev/null +++ b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py @@ -0,0 +1,31 @@ +import os +from typing import List + +import click + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies +from cycode.cli.models import Document + +GO_PROJECT_FILE_EXTENSIONS = ['.mod'] +GO_RESTORE_FILE_NAME = 'go.sum' +BUILD_GO_FILE_NAME = 'go.mod' + + +class RestoreGoDependencies(BaseRestoreDependencies): + def __init__(self, context: click.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(context, is_git_diff, command_timeout, create_output_file_manually=True) + + def is_project(self, document: Document) -> bool: + return any(document.path.endswith(ext) for ext in GO_PROJECT_FILE_EXTENSIONS) + + def get_command(self, manifest_file_path: str) -> List[str]: + return ['cd', self.prepare_tree_file_path_for_command(manifest_file_path), '&&', 'go', 'list', '-m', '-json'] + + def get_lock_file_name(self) -> str: + return GO_RESTORE_FILE_NAME + + def verify_restore_file_already_exist(self, restore_file_path: str) -> bool: + return os.path.isfile(restore_file_path) + + def prepare_tree_file_path_for_command(self, manifest_file_path: str) -> str: + return manifest_file_path.replace(os.sep + BUILD_GO_FILE_NAME, '') From 042d738debac34c4f9efb228bd6205b43aaa0d7c Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 6 Nov 2024 11:59:42 +0100 Subject: [PATCH 122/257] CM-41798 - Update README about pre-commit hook (#260) --- .pre-commit-hooks.yaml | 6 +++--- README.md | 38 ++++++++++++++++++++++++++++++++------ 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 44b0dd1c..40e7a614 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -1,11 +1,11 @@ - id: cycode - name: Cycode pre commit defender + name: Cycode Secrets pre-commit defender language: python language_version: python3 entry: cycode - args: [ '--no-progress-meter', 'scan', 'pre_commit' ] + args: [ '--no-progress-meter', 'scan', '--scan-type', 'secret', 'pre_commit' ] - id: cycode-sca - name: Cycode SCA pre commit defender + name: Cycode SCA pre-commit defender language: python language_version: python3 entry: cycode diff --git a/README.md b/README.md index dc625720..cb6ae032 100644 --- a/README.md +++ b/README.md @@ -199,33 +199,59 @@ export CYCODE_CLIENT_SECRET={your Cycode Secret Key} Cycode’s pre-commit hook can be set up within your local repository so that the Cycode CLI application will identify any issues with your code automatically before you commit it to your codebase. +> [!NOTE] +> pre-commit hook is only available to Secrets and SCA scans. + Perform the following steps to install the pre-commit hook: -1. Install the pre-commit framework: +1. Install the pre-commit framework (Python 3.8 or higher must be installed): `pip3 install pre-commit` -2. Navigate to the top directory of the local repository you wish to scan. +2. Navigate to the top directory of the local Git repository you wish to configure. 3. Create a new YAML file named `.pre-commit-config.yaml` (include the beginning `.`) in the repository’s top directory that contains the following: ```yaml repos: - repo: https://github.com/cycodehq/cycode-cli - rev: v1.4.0 + rev: v1.11.0 + hooks: + - id: cycode + stages: + - commit + ``` + +4. Modify the created file for your specific needs. Use hook ID `cycode` to enable scan for Secrets. Use hook ID `cycode-sca` to enable SCA scan. If you want to enable both, use this configuration: + + ```yaml + repos: + - repo: https://github.com/cycodehq/cycode-cli + rev: v1.11.0 hooks: - id: cycode stages: - commit + - id: cycode-sca + stages: + - commit ``` -4. Install Cycode’s hook: +5. Install Cycode’s hook: `pre-commit install` + A successful hook installation will result in the message: `Pre-commit installed at .git/hooks/pre-commit`. + +6. Keep the pre-commit hook up to date: + + `pre-commit autoupdate` + + It will automatically bump "rev" in ".pre-commit-config.yaml" to the latest available version of Cycode CLI. + > [!NOTE] -> A successful hook installation will result in the message:
-`Pre-commit installed at .git/hooks/pre-commit` +> Trigger happens on `git commit` command. +> Hook triggers only on the files that are staged for commit. # Cycode CLI Commands From bb2226231d13422df06112d4a0c2afc138ceb43e Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 6 Nov 2024 21:42:46 +0100 Subject: [PATCH 123/257] CM-41830 - Fix generating and uploading attestations to PyPI (#261) --- .github/workflows/pre_release.yml | 1 + .github/workflows/release.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 8909fca8..7aca89c1 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -25,6 +25,7 @@ jobs: install.python-poetry.org pypi.org upload.pypi.org + *.sigstore.dev - name: Checkout repository uses: actions/checkout@v3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e89885e9..40913767 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,6 +24,7 @@ jobs: install.python-poetry.org pypi.org upload.pypi.org + *.sigstore.dev - name: Checkout repository uses: actions/checkout@v3 From 84b298c75d419c978006212aa81d3f647aa26686 Mon Sep 17 00:00:00 2001 From: naftalicy Date: Thu, 7 Nov 2024 13:10:39 +0200 Subject: [PATCH 124/257] CM-41217 - Implement SBT restore support for SCA (#259) --- README.md | 13 ++++++++++ .../scan/repository/repository_command.py | 11 ++++++-- .../sca/base_restore_dependencies.py | 18 ++++++++++--- .../cli/files_collector/sca/sbt/__init__.py | 0 .../sca/sbt/restore_sbt_dependencies.py | 25 +++++++++++++++++++ .../files_collector/sca/sca_code_scanner.py | 14 ++++------- cycode/cli/models.py | 8 +++++- cycode/cli/utils/shell_executor.py | 8 ++++-- 8 files changed, 80 insertions(+), 17 deletions(-) create mode 100644 cycode/cli/files_collector/sca/sbt/__init__.py create mode 100644 cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py diff --git a/README.md b/README.md index cb6ae032..24e2b742 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ This guide will guide you through both installation and usage. 6. [Commit History Scan](#commit-history-scan) 1. [Commit Range Option](#commit-range-option) 7. [Pre-Commit Scan](#pre-commit-scan) + 8. [Lock Restore Options](#lock-restore-options) + 1. [SBT Scan](#sbt-scan) 2. [Scan Results](#scan-results) 1. [Show/Hide Secrets](#showhide-secrets) 2. [Soft Fail](#soft-fail) @@ -496,6 +498,17 @@ After your install the pre-commit hook and, you may, on occasion, wish to skip s `SKIP=cycode git commit -m ` +### Lock Restore Options + +#### SBT Scan + +We use sbt-dependency-lock plugin to restore the lock file for SBT projects. +To disable lock restore in use `--no-restore` option. + +Prerequisites +* sbt-dependency-lock Plugin: Install the plugin by adding the following line to `project/plugins.sbt`: +`addSbtPlugin("software.purpledragon" % "sbt-dependency-lock" % "1.5.1")` + ## Scan Results Each scan will complete with a message stating if any issues were found or not. diff --git a/cycode/cli/commands/scan/repository/repository_command.py b/cycode/cli/commands/scan/repository/repository_command.py index 87b8bbcc..9485c31c 100644 --- a/cycode/cli/commands/scan/repository/repository_command.py +++ b/cycode/cli/commands/scan/repository/repository_command.py @@ -48,8 +48,15 @@ def repository_command(context: click.Context, path: str, branch: str) -> None: # FIXME(MarshalX): probably file could be tree or submodule too. we expect blob only progress_bar.update(ScanProgressBarSection.PREPARE_LOCAL_FILES) - file_path = file.path if monitor else get_path_by_os(os.path.join(path, file.path)) - documents_to_scan.append(Document(file_path, file.data_stream.read().decode('UTF-8', errors='replace'))) + absolute_path = get_path_by_os(os.path.join(path, file.path)) + file_path = file.path if monitor else absolute_path + documents_to_scan.append( + Document( + file_path, + file.data_stream.read().decode('UTF-8', errors='replace'), + absolute_path=absolute_path, + ) + ) documents_to_scan = exclude_irrelevant_documents_to_scan(scan_type, documents_to_scan) diff --git a/cycode/cli/files_collector/sca/base_restore_dependencies.py b/cycode/cli/files_collector/sca/base_restore_dependencies.py index c64b0720..e0d7558a 100644 --- a/cycode/cli/files_collector/sca/base_restore_dependencies.py +++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py @@ -14,10 +14,14 @@ def build_dep_tree_path(path: str, generated_file_name: str) -> str: def execute_command( - command: List[str], file_name: str, command_timeout: int, dependencies_file_name: Optional[str] = None + command: List[str], + file_name: str, + command_timeout: int, + dependencies_file_name: Optional[str] = None, + working_directory: Optional[str] = None, ) -> Optional[str]: try: - dependencies = shell(command=command, timeout=command_timeout) + dependencies = shell(command=command, timeout=command_timeout, working_directory=working_directory) # Write stdout output to the file if output_file_path is provided if dependencies_file_name: with open(dependencies_file_name, 'w') as output_file: @@ -51,18 +55,26 @@ def get_manifest_file_path(self, document: Document) -> str: def try_restore_dependencies(self, document: Document) -> Optional[Document]: manifest_file_path = self.get_manifest_file_path(document) restore_file_path = build_dep_tree_path(document.path, self.get_lock_file_name()) + working_directory_path = self.get_working_directory(document) if self.verify_restore_file_already_exist(restore_file_path): restore_file_content = get_file_content(restore_file_path) else: output_file_path = restore_file_path if self.create_output_file_manually else None execute_command( - self.get_command(manifest_file_path), manifest_file_path, self.command_timeout, output_file_path + self.get_command(manifest_file_path), + manifest_file_path, + self.command_timeout, + output_file_path, + working_directory_path, ) restore_file_content = get_file_content(restore_file_path) return Document(restore_file_path, restore_file_content, self.is_git_diff) + def get_working_directory(self, document: Document) -> Optional[str]: + return None + @abstractmethod def verify_restore_file_already_exist(self, restore_file_path: str) -> bool: pass diff --git a/cycode/cli/files_collector/sca/sbt/__init__.py b/cycode/cli/files_collector/sca/sbt/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py b/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py new file mode 100644 index 00000000..f5073ef0 --- /dev/null +++ b/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py @@ -0,0 +1,25 @@ +import os +from typing import List, Optional + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies +from cycode.cli.models import Document + +SBT_PROJECT_FILE_EXTENSIONS = ['sbt'] +SBT_LOCK_FILE_NAME = 'build.sbt.lock' + + +class RestoreSbtDependencies(BaseRestoreDependencies): + def is_project(self, document: Document) -> bool: + return any(document.path.endswith(ext) for ext in SBT_PROJECT_FILE_EXTENSIONS) + + def get_command(self, manifest_file_path: str) -> List[str]: + return ['sbt', 'dependencyLockWrite', '--verbose'] + + def get_lock_file_name(self) -> str: + return SBT_LOCK_FILE_NAME + + def verify_restore_file_already_exist(self, restore_file_path: str) -> bool: + return os.path.isfile(restore_file_path) + + def get_working_directory(self, document: Document) -> Optional[str]: + return os.path.dirname(document.absolute_path) diff --git a/cycode/cli/files_collector/sca/sca_code_scanner.py b/cycode/cli/files_collector/sca/sca_code_scanner.py index 1090e7bf..9e5ac5b4 100644 --- a/cycode/cli/files_collector/sca/sca_code_scanner.py +++ b/cycode/cli/files_collector/sca/sca_code_scanner.py @@ -7,8 +7,7 @@ from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies from cycode.cli.files_collector.sca.maven.restore_gradle_dependencies import RestoreGradleDependencies from cycode.cli.files_collector.sca.maven.restore_maven_dependencies import RestoreMavenDependencies -from cycode.cli.files_collector.sca.npm.restore_npm_dependencies import RestoreNpmDependencies -from cycode.cli.files_collector.sca.nuget.restore_nuget_dependencies import RestoreNugetDependencies +from cycode.cli.files_collector.sca.sbt.restore_sbt_dependencies import RestoreSbtDependencies from cycode.cli.models import Document from cycode.cli.utils.git_proxy import git_proxy from cycode.cli.utils.path_utils import get_file_content, get_file_dir, get_path_from_context, join_paths @@ -17,9 +16,7 @@ if TYPE_CHECKING: from git import Repo -BUILD_GRADLE_DEP_TREE_TIMEOUT = 180 -BUILD_NUGET_DEP_TREE_TIMEOUT = 180 -BUILD_NPM_DEP_TREE_TIMEOUT = 180 +BUILD_DEP_TREE_TIMEOUT = 180 def perform_pre_commit_range_scan_actions( @@ -132,10 +129,9 @@ def add_dependencies_tree_document( def restore_handlers(context: click.Context, is_git_diff: bool) -> List[BaseRestoreDependencies]: return [ - RestoreGradleDependencies(context, is_git_diff, BUILD_GRADLE_DEP_TREE_TIMEOUT), - RestoreMavenDependencies(context, is_git_diff, BUILD_GRADLE_DEP_TREE_TIMEOUT), - RestoreNugetDependencies(context, is_git_diff, BUILD_NUGET_DEP_TREE_TIMEOUT), - RestoreNpmDependencies(context, is_git_diff, BUILD_NPM_DEP_TREE_TIMEOUT), + RestoreGradleDependencies(context, is_git_diff, BUILD_DEP_TREE_TIMEOUT), + RestoreMavenDependencies(context, is_git_diff, BUILD_DEP_TREE_TIMEOUT), + RestoreSbtDependencies(context, is_git_diff, BUILD_DEP_TREE_TIMEOUT), ] diff --git a/cycode/cli/models.py b/cycode/cli/models.py index 66846725..4d4d241c 100644 --- a/cycode/cli/models.py +++ b/cycode/cli/models.py @@ -7,12 +7,18 @@ class Document: def __init__( - self, path: str, content: str, is_git_diff_format: bool = False, unique_id: Optional[str] = None + self, + path: str, + content: str, + is_git_diff_format: bool = False, + unique_id: Optional[str] = None, + absolute_path: Optional[str] = None, ) -> None: self.path = path self.content = content self.is_git_diff_format = is_git_diff_format self.unique_id = unique_id + self.absolute_path = absolute_path def __repr__(self) -> str: return 'path:{0}, content:{1}'.format(self.path, self.content) diff --git a/cycode/cli/utils/shell_executor.py b/cycode/cli/utils/shell_executor.py index a0883d6d..5ac79518 100644 --- a/cycode/cli/utils/shell_executor.py +++ b/cycode/cli/utils/shell_executor.py @@ -8,12 +8,16 @@ _SUBPROCESS_DEFAULT_TIMEOUT_SEC = 60 -def shell(command: Union[str, List[str]], timeout: int = _SUBPROCESS_DEFAULT_TIMEOUT_SEC) -> Optional[str]: +def shell( + command: Union[str, List[str]], + timeout: int = _SUBPROCESS_DEFAULT_TIMEOUT_SEC, + working_directory: Optional[str] = None, +) -> Optional[str]: logger.debug('Executing shell command: %s', command) try: result = subprocess.run( # noqa: S603 - command, timeout=timeout, check=True, capture_output=True + command, cwd=working_directory, timeout=timeout, check=True, capture_output=True ) return result.stdout.decode('UTF-8').strip() From d86fd466a6bddcf78bff2580ff455fd67a8c9eb7 Mon Sep 17 00:00:00 2001 From: naftalicy Date: Thu, 7 Nov 2024 15:15:13 +0200 Subject: [PATCH 125/257] CM-41217 - Use absolute path instead of path in SCA restore (#262) --- cycode/cli/files_collector/sca/base_restore_dependencies.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cycode/cli/files_collector/sca/base_restore_dependencies.py b/cycode/cli/files_collector/sca/base_restore_dependencies.py index e0d7558a..6db1a678 100644 --- a/cycode/cli/files_collector/sca/base_restore_dependencies.py +++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py @@ -54,7 +54,7 @@ def get_manifest_file_path(self, document: Document) -> str: def try_restore_dependencies(self, document: Document) -> Optional[Document]: manifest_file_path = self.get_manifest_file_path(document) - restore_file_path = build_dep_tree_path(document.path, self.get_lock_file_name()) + restore_file_path = build_dep_tree_path(document.absolute_path, self.get_lock_file_name()) working_directory_path = self.get_working_directory(document) if self.verify_restore_file_already_exist(restore_file_path): From 1c39dd1f80cd18ad096d8ff40b61ed26e538f09f Mon Sep 17 00:00:00 2001 From: naftalicy Date: Thu, 7 Nov 2024 17:57:44 +0200 Subject: [PATCH 126/257] CM-41217 - Fix restore file in monitor SCA scanning (#263) --- cycode/cli/files_collector/sca/base_restore_dependencies.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cycode/cli/files_collector/sca/base_restore_dependencies.py b/cycode/cli/files_collector/sca/base_restore_dependencies.py index 6db1a678..b320e029 100644 --- a/cycode/cli/files_collector/sca/base_restore_dependencies.py +++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py @@ -55,6 +55,7 @@ def get_manifest_file_path(self, document: Document) -> str: def try_restore_dependencies(self, document: Document) -> Optional[Document]: manifest_file_path = self.get_manifest_file_path(document) restore_file_path = build_dep_tree_path(document.absolute_path, self.get_lock_file_name()) + relative_restore_file_path = build_dep_tree_path(document.path, self.get_lock_file_name()) working_directory_path = self.get_working_directory(document) if self.verify_restore_file_already_exist(restore_file_path): @@ -70,7 +71,7 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: ) restore_file_content = get_file_content(restore_file_path) - return Document(restore_file_path, restore_file_content, self.is_git_diff) + return Document(relative_restore_file_path, restore_file_content, self.is_git_diff) def get_working_directory(self, document: Document) -> Optional[str]: return None From 0da58baec540a38f1a9c8c18560775b5accba09e Mon Sep 17 00:00:00 2001 From: naftalicy Date: Tue, 12 Nov 2024 12:02:00 +0200 Subject: [PATCH 127/257] CM-41217 - Fix absolute path for local path scanning (#264) --- cycode/cli/files_collector/path_documents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cycode/cli/files_collector/path_documents.py b/cycode/cli/files_collector/path_documents.py index ac63c9e3..98a021e4 100644 --- a/cycode/cli/files_collector/path_documents.py +++ b/cycode/cli/files_collector/path_documents.py @@ -84,7 +84,7 @@ def _generate_document(file: str, scan_type: str, content: str, is_git_diff: boo if is_iac(scan_type) and is_tfplan_file(file, content): return _handle_tfplan_file(file, content, is_git_diff) - return Document(file, content, is_git_diff) + return Document(file, content, is_git_diff, absolute_path=file) def _handle_tfplan_file(file: str, content: str, is_git_diff: bool) -> Document: From 823e77656ff449c81ab88bff40d414d80c1bddc8 Mon Sep 17 00:00:00 2001 From: naftalicy Date: Wed, 13 Nov 2024 13:37:18 +0200 Subject: [PATCH 128/257] CM-40909 - Add lock file restore for Golang, NPM and Nuget (#266) --- .../sca/base_restore_dependencies.py | 26 +++++++++----- .../sca/go/restore_go_dependencies.py | 34 +++++++++++++++---- .../sca/maven/restore_gradle_dependencies.py | 4 +-- .../sca/maven/restore_maven_dependencies.py | 8 ++--- .../sca/npm/restore_npm_dependencies.py | 18 +++++----- .../sca/nuget/restore_nuget_dependencies.py | 4 +-- .../sca/sbt/restore_sbt_dependencies.py | 4 +-- .../files_collector/sca/sca_code_scanner.py | 6 ++++ 8 files changed, 70 insertions(+), 34 deletions(-) diff --git a/cycode/cli/files_collector/sca/base_restore_dependencies.py b/cycode/cli/files_collector/sca/base_restore_dependencies.py index b320e029..81caea1d 100644 --- a/cycode/cli/files_collector/sca/base_restore_dependencies.py +++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py @@ -13,19 +13,27 @@ def build_dep_tree_path(path: str, generated_file_name: str) -> str: return join_paths(get_file_dir(path), generated_file_name) -def execute_command( - command: List[str], +def execute_commands( + commands: List[List[str]], file_name: str, command_timeout: int, dependencies_file_name: Optional[str] = None, working_directory: Optional[str] = None, ) -> Optional[str]: try: - dependencies = shell(command=command, timeout=command_timeout, working_directory=working_directory) - # Write stdout output to the file if output_file_path is provided + all_dependencies = [] + + # Run all commands and collect outputs + for command in commands: + dependencies = shell(command=command, timeout=command_timeout, working_directory=working_directory) + all_dependencies.append(dependencies) # Collect each command's output + + dependencies = '\n'.join(all_dependencies) + + # Write all collected outputs to the file if dependencies_file_name is provided if dependencies_file_name: - with open(dependencies_file_name, 'w') as output_file: - output_file.write(dependencies) + with open(dependencies_file_name, 'w') as output_file: # Open once in 'w' mode to start fresh + output_file.writelines(dependencies) except Exception as e: logger.debug('Failed to restore dependencies via shell command, %s', {'filename': file_name}, exc_info=e) return None @@ -62,8 +70,8 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: restore_file_content = get_file_content(restore_file_path) else: output_file_path = restore_file_path if self.create_output_file_manually else None - execute_command( - self.get_command(manifest_file_path), + execute_commands( + self.get_commands(manifest_file_path), manifest_file_path, self.command_timeout, output_file_path, @@ -85,7 +93,7 @@ def is_project(self, document: Document) -> bool: pass @abstractmethod - def get_command(self, manifest_file_path: str) -> List[str]: + def get_commands(self, manifest_file_path: str) -> List[List[str]]: pass @abstractmethod diff --git a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py index 512af8a5..1986b3a2 100644 --- a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py +++ b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py @@ -1,25 +1,45 @@ +import logging import os -from typing import List +from typing import List, Optional import click from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies from cycode.cli.models import Document -GO_PROJECT_FILE_EXTENSIONS = ['.mod'] -GO_RESTORE_FILE_NAME = 'go.sum' +GO_PROJECT_FILE_EXTENSIONS = ['.mod', '.sum'] +GO_RESTORE_FILE_NAME = 'go.mod.graph' BUILD_GO_FILE_NAME = 'go.mod' +BUILD_GO_LOCK_FILE_NAME = 'go.sum' class RestoreGoDependencies(BaseRestoreDependencies): def __init__(self, context: click.Context, is_git_diff: bool, command_timeout: int) -> None: super().__init__(context, is_git_diff, command_timeout, create_output_file_manually=True) + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_exists = os.path.isfile(self.get_working_directory(document) + os.sep + BUILD_GO_FILE_NAME) + lock_exists = os.path.isfile(self.get_working_directory(document) + os.sep + BUILD_GO_LOCK_FILE_NAME) + + if not manifest_exists or not lock_exists: + logging.info('No manifest go.mod file found' if not manifest_exists else 'No manifest go.sum file found') + + manifest_files_exists = manifest_exists & lock_exists + + if not manifest_files_exists: + return None + + return super().try_restore_dependencies(document) + def is_project(self, document: Document) -> bool: return any(document.path.endswith(ext) for ext in GO_PROJECT_FILE_EXTENSIONS) - def get_command(self, manifest_file_path: str) -> List[str]: - return ['cd', self.prepare_tree_file_path_for_command(manifest_file_path), '&&', 'go', 'list', '-m', '-json'] + def get_commands(self, manifest_file_path: str) -> List[List[str]]: + return [ + ['go', 'list', '-m', '-json', 'all'], + ['echo', '------------------------------------------------------'], + ['go', 'mod', 'graph'], + ] def get_lock_file_name(self) -> str: return GO_RESTORE_FILE_NAME @@ -27,5 +47,5 @@ def get_lock_file_name(self) -> str: def verify_restore_file_already_exist(self, restore_file_path: str) -> bool: return os.path.isfile(restore_file_path) - def prepare_tree_file_path_for_command(self, manifest_file_path: str) -> str: - return manifest_file_path.replace(os.sep + BUILD_GO_FILE_NAME, '') + def get_working_directory(self, document: Document) -> Optional[str]: + return os.path.dirname(document.absolute_path) diff --git a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py index f925c28e..04fc6b9c 100644 --- a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py @@ -18,8 +18,8 @@ def __init__(self, context: click.Context, is_git_diff: bool, command_timeout: i def is_project(self, document: Document) -> bool: return document.path.endswith(BUILD_GRADLE_FILE_NAME) or document.path.endswith(BUILD_GRADLE_KTS_FILE_NAME) - def get_command(self, manifest_file_path: str) -> List[str]: - return ['gradle', 'dependencies', '-b', manifest_file_path, '-q', '--console', 'plain'] + def get_commands(self, manifest_file_path: str) -> List[List[str]]: + return [['gradle', 'dependencies', '-b', manifest_file_path, '-q', '--console', 'plain']] def get_lock_file_name(self) -> str: return BUILD_GRADLE_DEP_TREE_FILE_NAME diff --git a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py index 84732021..a44a27e0 100644 --- a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py @@ -7,7 +7,7 @@ from cycode.cli.files_collector.sca.base_restore_dependencies import ( BaseRestoreDependencies, build_dep_tree_path, - execute_command, + execute_commands, ) from cycode.cli.models import Document from cycode.cli.utils.path_utils import get_file_content, get_file_dir, join_paths @@ -24,8 +24,8 @@ def __init__(self, context: click.Context, is_git_diff: bool, command_timeout: i def is_project(self, document: Document) -> bool: return path.basename(document.path).split('/')[-1] == BUILD_MAVEN_FILE_NAME - def get_command(self, manifest_file_path: str) -> List[str]: - return ['mvn', 'org.cyclonedx:cyclonedx-maven-plugin:2.7.4:makeAggregateBom', '-f', manifest_file_path] + def get_commands(self, manifest_file_path: str) -> List[List[str]]: + return [['mvn', 'org.cyclonedx:cyclonedx-maven-plugin:2.7.4:makeAggregateBom', '-f', manifest_file_path]] def get_lock_file_name(self) -> str: return join_paths('target', MAVEN_CYCLONE_DEP_TREE_FILE_NAME) @@ -52,7 +52,7 @@ def restore_from_secondary_command( ) -> Optional[Document]: # TODO(MarshalX): does it even work? Ignored restore_dependencies_document arg secondary_restore_command = create_secondary_restore_command(manifest_file_path) - backup_restore_content = execute_command(secondary_restore_command, manifest_file_path, self.command_timeout) + backup_restore_content = execute_commands(secondary_restore_command, manifest_file_path, self.command_timeout) restore_dependencies_document = Document( build_dep_tree_path(document.path, MAVEN_DEP_TREE_FILE_NAME), backup_restore_content, self.is_git_diff ) diff --git a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py index ea6f4061..c3026938 100644 --- a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py +++ b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py @@ -18,15 +18,17 @@ def __init__(self, context: click.Context, is_git_diff: bool, command_timeout: i def is_project(self, document: Document) -> bool: return any(document.path.endswith(ext) for ext in NPM_PROJECT_FILE_EXTENSIONS) - def get_command(self, manifest_file_path: str) -> List[str]: + def get_commands(self, manifest_file_path: str) -> List[List[str]]: return [ - 'npm', - 'install', - '--prefix', - self.prepare_manifest_file_path_for_command(manifest_file_path), - '--package-lock-only', - '--ignore-scripts', - '--no-audit', + [ + 'npm', + 'install', + '--prefix', + self.prepare_manifest_file_path_for_command(manifest_file_path), + '--package-lock-only', + '--ignore-scripts', + '--no-audit', + ] ] def get_lock_file_name(self) -> str: diff --git a/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py b/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py index c54c3e5e..0e2ed83d 100644 --- a/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py +++ b/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py @@ -17,8 +17,8 @@ def __init__(self, context: click.Context, is_git_diff: bool, command_timeout: i def is_project(self, document: Document) -> bool: return any(document.path.endswith(ext) for ext in NUGET_PROJECT_FILE_EXTENSIONS) - def get_command(self, manifest_file_path: str) -> List[str]: - return ['dotnet', 'restore', manifest_file_path, '--use-lock-file', '--verbosity', 'quiet'] + def get_commands(self, manifest_file_path: str) -> List[List[str]]: + return [['dotnet', 'restore', manifest_file_path, '--use-lock-file', '--verbosity', 'quiet']] def get_lock_file_name(self) -> str: return NUGET_LOCK_FILE_NAME diff --git a/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py b/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py index f5073ef0..b8e1c41b 100644 --- a/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py +++ b/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py @@ -12,8 +12,8 @@ class RestoreSbtDependencies(BaseRestoreDependencies): def is_project(self, document: Document) -> bool: return any(document.path.endswith(ext) for ext in SBT_PROJECT_FILE_EXTENSIONS) - def get_command(self, manifest_file_path: str) -> List[str]: - return ['sbt', 'dependencyLockWrite', '--verbose'] + def get_commands(self, manifest_file_path: str) -> List[List[str]]: + return [['sbt', 'dependencyLockWrite', '--verbose']] def get_lock_file_name(self) -> str: return SBT_LOCK_FILE_NAME diff --git a/cycode/cli/files_collector/sca/sca_code_scanner.py b/cycode/cli/files_collector/sca/sca_code_scanner.py index 9e5ac5b4..d13d486c 100644 --- a/cycode/cli/files_collector/sca/sca_code_scanner.py +++ b/cycode/cli/files_collector/sca/sca_code_scanner.py @@ -5,8 +5,11 @@ from cycode.cli import consts from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies +from cycode.cli.files_collector.sca.go.restore_go_dependencies import RestoreGoDependencies from cycode.cli.files_collector.sca.maven.restore_gradle_dependencies import RestoreGradleDependencies from cycode.cli.files_collector.sca.maven.restore_maven_dependencies import RestoreMavenDependencies +from cycode.cli.files_collector.sca.npm.restore_npm_dependencies import RestoreNpmDependencies +from cycode.cli.files_collector.sca.nuget.restore_nuget_dependencies import RestoreNugetDependencies from cycode.cli.files_collector.sca.sbt.restore_sbt_dependencies import RestoreSbtDependencies from cycode.cli.models import Document from cycode.cli.utils.git_proxy import git_proxy @@ -132,6 +135,9 @@ def restore_handlers(context: click.Context, is_git_diff: bool) -> List[BaseRest RestoreGradleDependencies(context, is_git_diff, BUILD_DEP_TREE_TIMEOUT), RestoreMavenDependencies(context, is_git_diff, BUILD_DEP_TREE_TIMEOUT), RestoreSbtDependencies(context, is_git_diff, BUILD_DEP_TREE_TIMEOUT), + RestoreGoDependencies(context, is_git_diff, BUILD_DEP_TREE_TIMEOUT), + RestoreNugetDependencies(context, is_git_diff, BUILD_DEP_TREE_TIMEOUT), + RestoreNpmDependencies(context, is_git_diff, BUILD_DEP_TREE_TIMEOUT), ] From 56a773b6d2566331fd55e849c251e1fb661e59b8 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 13 Nov 2024 12:40:17 +0100 Subject: [PATCH 129/257] CM-41489 - Increase default sync scans timeout; make sync scan timeout configurable via env var (#267) --- cycode/cli/consts.py | 4 ++++ cycode/cli/user_settings/configuration_manager.py | 7 +++++++ cycode/cyclient/scan_client.py | 3 ++- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index a215bf9a..4304580a 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -155,6 +155,10 @@ SENTRY_INCLUDE_LOCAL_VARIABLES = False SENTRY_MAX_REQUEST_BODY_SIZE = 'never' +# sync scans +SYNC_SCAN_TIMEOUT_IN_SECONDS_ENV_VAR_NAME = 'SYNC_SCAN_TIMEOUT_IN_SECONDS' +DEFAULT_SYNC_SCAN_TIMEOUT_IN_SECONDS = 180 + # report with polling REPORT_POLLING_WAIT_INTERVAL_IN_SECONDS = 5 DEFAULT_REPORT_POLLING_TIMEOUT_IN_SECONDS = 600 diff --git a/cycode/cli/user_settings/configuration_manager.py b/cycode/cli/user_settings/configuration_manager.py index ff39470a..909a97f0 100644 --- a/cycode/cli/user_settings/configuration_manager.py +++ b/cycode/cli/user_settings/configuration_manager.py @@ -106,6 +106,13 @@ def get_scan_polling_timeout_in_seconds(self) -> int: ) ) + def get_sync_scan_timeout_in_seconds(self) -> int: + return int( + self._get_value_from_environment_variables( + consts.SYNC_SCAN_TIMEOUT_IN_SECONDS_ENV_VAR_NAME, consts.DEFAULT_SYNC_SCAN_TIMEOUT_IN_SECONDS + ) + ) + def get_report_polling_timeout_in_seconds(self) -> int: return int( self._get_value_from_environment_variables( diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index 2e494cb0..5b687300 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -4,6 +4,7 @@ from requests import Response from cycode.cli import consts +from cycode.cli.config import configuration_manager from cycode.cli.exceptions.custom_exceptions import CycodeError from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip from cycode.cyclient import models @@ -133,7 +134,7 @@ def zipped_file_scan_sync( }, files=files, hide_response_content_log=self._hide_response_log, - timeout=60, + timeout=configuration_manager.get_sync_scan_timeout_in_seconds(), ) return models.ScanResultsSyncFlowSchema().load(response.json()) From 44df1d6f8a8e27e7191b4a231fcc3bcd73baea0d Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 20 Nov 2024 17:20:03 +0100 Subject: [PATCH 130/257] CM-26891 - Add `cycode status` command (#268) --- cycode/cli/commands/auth/auth_command.py | 41 ++----- cycode/cli/commands/auth_common.py | 33 +++++ cycode/cli/commands/main_cli.py | 2 + cycode/cli/commands/status/__init__.py | 0 cycode/cli/commands/status/status_command.py | 122 +++++++++++++++++++ cycode/cyclient/models.py | 38 ++++++ cycode/cyclient/scan_client.py | 8 +- 7 files changed, 211 insertions(+), 33 deletions(-) create mode 100644 cycode/cli/commands/auth_common.py create mode 100644 cycode/cli/commands/status/__init__.py create mode 100644 cycode/cli/commands/status/status_command.py diff --git a/cycode/cli/commands/auth/auth_command.py b/cycode/cli/commands/auth/auth_command.py index 4c377737..0862db2b 100644 --- a/cycode/cli/commands/auth/auth_command.py +++ b/cycode/cli/commands/auth/auth_command.py @@ -1,19 +1,15 @@ import click from cycode.cli.commands.auth.auth_manager import AuthManager +from cycode.cli.commands.auth_common import get_authorization_info from cycode.cli.exceptions.custom_exceptions import ( KNOWN_USER_FRIENDLY_REQUEST_ERRORS, AuthProcessError, - HttpUnauthorizedError, - RequestHttpError, ) 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 @click.group( @@ -49,35 +45,18 @@ def authorization_check(context: click.Context) -> None: add_breadcrumb('check') printer = ConsolePrinter(context) - - failed_auth_check_res = CliResult(success=False, message='Cycode authentication failed') - - client_id, client_secret = CredentialsManager().get_credentials() - if not client_id or not client_secret: - printer.print_result(failed_auth_check_res) + auth_info = get_authorization_info(context) + if auth_info is None: + printer.print_result(CliResult(success=False, message='Cycode authentication failed')) return - try: - 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}, - ) + printer.print_result( + CliResult( + success=True, + message='Cycode authentication verified', + data={'user_id': auth_info.user_id, 'tenant_id': auth_info.tenant_id}, ) - - return - except (RequestHttpError, HttpUnauthorizedError): - ConsolePrinter(context).print_exception() - - printer.print_result(failed_auth_check_res) - return + ) def _handle_exception(context: click.Context, e: Exception) -> None: diff --git a/cycode/cli/commands/auth_common.py b/cycode/cli/commands/auth_common.py new file mode 100644 index 00000000..bf8d5d41 --- /dev/null +++ b/cycode/cli/commands/auth_common.py @@ -0,0 +1,33 @@ +from typing import NamedTuple, Optional + +import click + +from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError, RequestHttpError +from cycode.cli.printers import ConsolePrinter +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.cycode_token_based_client import CycodeTokenBasedClient + + +class AuthInfo(NamedTuple): + user_id: str + tenant_id: str + + +def get_authorization_info(context: Optional[click.Context] = None) -> Optional[AuthInfo]: + client_id, client_secret = CredentialsManager().get_credentials() + if not client_id or not client_secret: + return None + + try: + access_token = CycodeTokenBasedClient(client_id, client_secret).get_access_token() + if not access_token: + return None + + user_id, tenant_id = get_user_and_tenant_ids_from_access_token(access_token) + return AuthInfo(user_id=user_id, tenant_id=tenant_id) + except (RequestHttpError, HttpUnauthorizedError): + if context: + ConsolePrinter(context).print_exception() + + return None diff --git a/cycode/cli/commands/main_cli.py b/cycode/cli/commands/main_cli.py index 5a81cbb9..67bb8171 100644 --- a/cycode/cli/commands/main_cli.py +++ b/cycode/cli/commands/main_cli.py @@ -8,6 +8,7 @@ from cycode.cli.commands.ignore.ignore_command import ignore_command from cycode.cli.commands.report.report_command import report_command from cycode.cli.commands.scan.scan_command import scan_command +from cycode.cli.commands.status.status_command import status_command from cycode.cli.commands.version.version_command import version_command from cycode.cli.consts import ( CLI_CONTEXT_SETTINGS, @@ -28,6 +29,7 @@ 'ignore': ignore_command, 'auth': auth_command, 'version': version_command, + 'status': status_command, }, context_settings=CLI_CONTEXT_SETTINGS, ) diff --git a/cycode/cli/commands/status/__init__.py b/cycode/cli/commands/status/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/commands/status/status_command.py b/cycode/cli/commands/status/status_command.py new file mode 100644 index 00000000..f5d9aec3 --- /dev/null +++ b/cycode/cli/commands/status/status_command.py @@ -0,0 +1,122 @@ +import dataclasses +import json +import platform +from typing import Dict + +import click + +from cycode import __version__ +from cycode.cli.commands.auth_common import get_authorization_info +from cycode.cli.consts import PROGRAM_NAME +from cycode.cli.user_settings.configuration_manager import ConfigurationManager +from cycode.cli.utils.get_api_client import get_scan_cycode_client +from cycode.cyclient import logger + + +class CliStatusBase: + def as_dict(self) -> Dict[str, any]: + return dataclasses.asdict(self) + + def _get_text_message_part(self, key: str, value: any, intent: int = 0) -> str: + message_parts = [] + + intent_prefix = ' ' * intent * 2 + human_readable_key = key.replace('_', ' ').capitalize() + + if isinstance(value, dict): + message_parts.append(f'{intent_prefix}{human_readable_key}:') + for sub_key, sub_value in value.items(): + message_parts.append(self._get_text_message_part(sub_key, sub_value, intent=intent + 1)) + elif isinstance(value, (list, set, tuple)): + message_parts.append(f'{intent_prefix}{human_readable_key}:') + for index, sub_value in enumerate(value): + message_parts.append(self._get_text_message_part(f'#{index}', sub_value, intent=intent + 1)) + else: + message_parts.append(f'{intent_prefix}{human_readable_key}: {value}') + + return '\n'.join(message_parts) + + def as_text(self) -> str: + message_parts = [] + for key, value in self.as_dict().items(): + message_parts.append(self._get_text_message_part(key, value)) + + return '\n'.join(message_parts) + + def as_json(self) -> str: + return json.dumps(self.as_dict()) + + +@dataclasses.dataclass +class CliSupportedModulesStatus(CliStatusBase): + secret_scanning: bool = False + sca_scanning: bool = False + iac_scanning: bool = False + sast_scanning: bool = False + ai_large_language_model: bool = False + + +@dataclasses.dataclass +class CliStatus(CliStatusBase): + program: str + version: str + os: str + arch: str + python_version: str + installation_id: str + app_url: str + api_url: str + is_authenticated: bool + user_id: str = None + tenant_id: str = None + supported_modules: CliSupportedModulesStatus = None + + +def get_cli_status() -> CliStatus: + configuration_manager = ConfigurationManager() + + auth_info = get_authorization_info() + is_authenticated = auth_info is not None + + supported_modules_status = CliSupportedModulesStatus() + if is_authenticated: + try: + client = get_scan_cycode_client() + supported_modules_preferences = client.get_supported_modules_preferences() + + supported_modules_status.secret_scanning = supported_modules_preferences.secret_scanning + supported_modules_status.sca_scanning = supported_modules_preferences.sca_scanning + supported_modules_status.iac_scanning = supported_modules_preferences.iac_scanning + supported_modules_status.sast_scanning = supported_modules_preferences.sast_scanning + supported_modules_status.ai_large_language_model = supported_modules_preferences.ai_large_language_model + except Exception as e: + logger.debug('Failed to get supported modules preferences', exc_info=e) + + return CliStatus( + program=PROGRAM_NAME, + version=__version__, + os=platform.system(), + arch=platform.machine(), + python_version=platform.python_version(), + installation_id=configuration_manager.get_or_create_installation_id(), + app_url=configuration_manager.get_cycode_app_url(), + api_url=configuration_manager.get_cycode_api_url(), + is_authenticated=is_authenticated, + user_id=auth_info.user_id if auth_info else None, + tenant_id=auth_info.tenant_id if auth_info else None, + supported_modules=supported_modules_status, + ) + + +@click.command(short_help='Show the CLI status and exit.') +@click.pass_context +def status_command(context: click.Context) -> None: + output = context.obj['output'] + + status = get_cli_status() + message = status.as_text() + if output == 'json': + message = status.as_json() + + click.echo(message, color=context.color) + context.exit() diff --git a/cycode/cyclient/models.py b/cycode/cyclient/models.py index 894e6444..a2162ba8 100644 --- a/cycode/cyclient/models.py +++ b/cycode/cyclient/models.py @@ -478,3 +478,41 @@ class Meta: @post_load def build_dto(self, data: Dict[str, Any], **_) -> ScanResultsSyncFlow: return ScanResultsSyncFlow(**data) + + +@dataclass +class SupportedModulesPreferences: + secret_scanning: bool + leak_scanning: bool + iac_scanning: bool + sca_scanning: bool + ci_cd_scanning: bool + sast_scanning: bool + container_scanning: bool + access_review: bool + asoc: bool + cimon: bool + ai_machine_learning: bool + ai_large_language_model: bool + + +class SupportedModulesPreferencesSchema(Schema): + class Meta: + unknown = EXCLUDE + + secret_scanning = fields.Boolean() + leak_scanning = fields.Boolean() + iac_scanning = fields.Boolean() + sca_scanning = fields.Boolean() + ci_cd_scanning = fields.Boolean() + sast_scanning = fields.Boolean() + container_scanning = fields.Boolean() + access_review = fields.Boolean() + asoc = fields.Boolean() + cimon = fields.Boolean() + ai_machine_learning = fields.Boolean() + ai_large_language_model = fields.Boolean() + + @post_load + def build_dto(self, data: Dict[str, Any], **_) -> 'SupportedModulesPreferences': + return SupportedModulesPreferences(**data) diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index 5b687300..744d73e2 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -27,7 +27,7 @@ def __init__( self._DETECTIONS_SERVICE_CONTROLLER_PATH = 'api/v1/detections' self._DETECTIONS_SERVICE_CLI_CONTROLLER_PATH = 'api/v1/detections/cli' - self.POLICIES_SERVICE_CONTROLLER_PATH_V3 = 'api/v3/policies' + self._POLICIES_SERVICE_CONTROLLER_PATH_V3 = 'api/v3/policies' self._hide_response_log = hide_response_log @@ -198,10 +198,14 @@ def get_scan_details(self, scan_type: str, scan_id: str) -> models.ScanDetailsRe def get_detection_rules_path(self) -> str: return ( f'{self.scan_config.get_detections_prefix()}/' - f'{self.POLICIES_SERVICE_CONTROLLER_PATH_V3}/' + f'{self._POLICIES_SERVICE_CONTROLLER_PATH_V3}/' f'detection_rules/byIds' ) + def get_supported_modules_preferences(self) -> models.SupportedModulesPreferences: + response = self.scan_cycode_client.get(url_path='preferences/api/v1/supportedmodules') + return models.SupportedModulesPreferencesSchema().load(response.json()) + @staticmethod def _get_policy_type_by_scan_type(scan_type: str) -> str: scan_type_to_policy_type = { From c8f9b1204a23490a7436d22b00b01e4fc663ad4b Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Fri, 22 Nov 2024 11:19:35 +0100 Subject: [PATCH 131/257] CM-26869 - Add `--severity-threshold` support for all scan types (#269) --- README.md | 2 +- cycode/cli/commands/scan/code_scanner.py | 21 +++++++++++------ cycode/cli/commands/scan/scan_command.py | 2 +- cycode/cli/models.py | 1 + tests/cli/commands/scan/test_code_scanner.py | 22 ++++++++++++++++++ tests/cli/models/__init__.py | 0 tests/cli/models/test_severity.py | 24 ++++++++++++++++++++ 7 files changed, 63 insertions(+), 9 deletions(-) create mode 100644 tests/cli/models/__init__.py create mode 100644 tests/cli/models/test_severity.py diff --git a/README.md b/README.md index 24e2b742..9f48da38 100644 --- a/README.md +++ b/README.md @@ -287,7 +287,7 @@ The Cycode CLI application offers several types of scans so that you can choose | `--client-id TEXT` | Specify a Cycode client ID for this specific scan execution | | `--show-secret BOOLEAN` | Show secrets in plain text. See [Show/Hide Secrets](#showhide-secrets) section for more details. | | `--soft-fail BOOLEAN` | Run scan without failing, always return a non-error status code. See [Soft Fail](#soft-fail) section for more details. | -| `--severity-threshold [INFO\|LOW\|MEDIUM\|HIGH\|CRITICAL]` | Show only violations at the specified level or higher (supported for the SCA scan type only). | +| `--severity-threshold [INFO\|LOW\|MEDIUM\|HIGH\|CRITICAL]` | Show only violations at the specified level or higher. | | `--sca-scan` | Specify the SCA scan you wish to execute (`package-vulnerabilities`/`license-compliance`). The default is both | | `--monitor` | When specified, the scan results will be recorded in the knowledge graph. Please note that when working in `monitor` mode, the knowledge graph will not be updated as a result of SCM events (Push, Repo creation). (Supported for SCA scan type only). | | `--report` | When specified, a violations report will be generated. A URL link to the report will be printed as an output to the command execution | diff --git a/cycode/cli/commands/scan/code_scanner.py b/cycode/cli/commands/scan/code_scanner.py index 4f9772e2..47d017b0 100644 --- a/cycode/cli/commands/scan/code_scanner.py +++ b/cycode/cli/commands/scan/code_scanner.py @@ -713,20 +713,26 @@ def exclude_irrelevant_detections( ) -> List[Detection]: relevant_detections = _exclude_detections_by_exclusions_configuration(detections, scan_type) relevant_detections = _exclude_detections_by_scan_type(relevant_detections, scan_type, command_scan_type) - return _exclude_detections_by_severity(relevant_detections, scan_type, severity_threshold) + return _exclude_detections_by_severity(relevant_detections, severity_threshold) -def _exclude_detections_by_severity( - detections: List[Detection], scan_type: str, severity_threshold: str -) -> List[Detection]: - if scan_type != consts.SCA_SCAN_TYPE or severity_threshold is None: +def _exclude_detections_by_severity(detections: List[Detection], severity_threshold: str) -> List[Detection]: + if severity_threshold is None: return detections relevant_detections = [] for detection in detections: severity = detection.detection_details.get('advisory_severity') + if not severity: + severity = detection.severity + if _does_severity_match_severity_threshold(severity, severity_threshold): relevant_detections.append(detection) + else: + logger.debug( + 'Going to ignore violations because they are below the severity threshold, %s', + {'severity': severity, 'severity_threshold': severity_threshold}, + ) return relevant_detections @@ -861,10 +867,11 @@ def _generate_unique_id() -> UUID: def _does_severity_match_severity_threshold(severity: str, severity_threshold: str) -> bool: detection_severity_value = Severity.try_get_value(severity) - if detection_severity_value is None: + severity_threshold_value = Severity.try_get_value(severity_threshold) + if detection_severity_value is None or severity_threshold_value is None: return True - return detection_severity_value >= Severity.try_get_value(severity_threshold) + return detection_severity_value >= severity_threshold_value def _get_scan_result( diff --git a/cycode/cli/commands/scan/scan_command.py b/cycode/cli/commands/scan/scan_command.py index d394f8c7..a428a87a 100644 --- a/cycode/cli/commands/scan/scan_command.py +++ b/cycode/cli/commands/scan/scan_command.py @@ -66,7 +66,7 @@ @click.option( '--severity-threshold', default=None, - help='Show violations only for the specified level or higher (supported for SCA scan types only).', + help='Show violations only for the specified level or higher.', type=click.Choice([e.name for e in Severity]), required=False, ) diff --git a/cycode/cli/models.py b/cycode/cli/models.py index 4d4d241c..39c07e44 100644 --- a/cycode/cli/models.py +++ b/cycode/cli/models.py @@ -43,6 +43,7 @@ class Severity(Enum): @staticmethod def try_get_value(name: str) -> any: + name = name.upper() if name not in Severity.__members__: return None diff --git a/tests/cli/commands/scan/test_code_scanner.py b/tests/cli/commands/scan/test_code_scanner.py index 7b739a82..2c15dd3d 100644 --- a/tests/cli/commands/scan/test_code_scanner.py +++ b/tests/cli/commands/scan/test_code_scanner.py @@ -1,6 +1,7 @@ import os from cycode.cli import consts +from cycode.cli.commands.scan.code_scanner import _does_severity_match_severity_threshold from cycode.cli.files_collector.excluder import _is_file_relevant_for_sca_scan from cycode.cli.files_collector.path_documents import _generate_document from cycode.cli.models import Document @@ -72,3 +73,24 @@ def test_generate_document() -> None: assert isinstance(generated_tfplan_document, Document) assert generated_tfplan_document.path.endswith('.tf') assert generated_tfplan_document.is_git_diff_format == is_git_diff + + +def test_does_severity_match_severity_threshold() -> None: + assert _does_severity_match_severity_threshold('INFO', 'LOW') is False + + assert _does_severity_match_severity_threshold('LOW', 'LOW') is True + assert _does_severity_match_severity_threshold('LOW', 'MEDIUM') is False + + assert _does_severity_match_severity_threshold('MEDIUM', 'LOW') is True + assert _does_severity_match_severity_threshold('MEDIUM', 'MEDIUM') is True + assert _does_severity_match_severity_threshold('MEDIUM', 'HIGH') is False + + assert _does_severity_match_severity_threshold('HIGH', 'MEDIUM') is True + assert _does_severity_match_severity_threshold('HIGH', 'HIGH') is True + assert _does_severity_match_severity_threshold('HIGH', 'CRITICAL') is False + + assert _does_severity_match_severity_threshold('CRITICAL', 'HIGH') is True + assert _does_severity_match_severity_threshold('CRITICAL', 'CRITICAL') is True + + assert _does_severity_match_severity_threshold('NON_EXISTENT', 'LOW') is True + assert _does_severity_match_severity_threshold('LOW', 'NON_EXISTENT') is True diff --git a/tests/cli/models/__init__.py b/tests/cli/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/models/test_severity.py b/tests/cli/models/test_severity.py new file mode 100644 index 00000000..332f987c --- /dev/null +++ b/tests/cli/models/test_severity.py @@ -0,0 +1,24 @@ +from cycode.cli.models import Severity + + +def test_try_get_value() -> None: + assert Severity.try_get_value('info') == -1 + assert Severity.try_get_value('iNfO') == -1 + + assert Severity.try_get_value('INFO') == -1 + assert Severity.try_get_value('LOW') == 0 + assert Severity.try_get_value('MEDIUM') == 1 + assert Severity.try_get_value('HIGH') == 2 + assert Severity.try_get_value('CRITICAL') == 3 + + assert Severity.try_get_value('NON_EXISTENT') is None + + +def test_get_member_weight() -> None: + assert Severity.get_member_weight('INFO') == -1 + assert Severity.get_member_weight('LOW') == 0 + assert Severity.get_member_weight('MEDIUM') == 1 + assert Severity.get_member_weight('HIGH') == 2 + assert Severity.get_member_weight('CRITICAL') == 3 + + assert Severity.get_member_weight('NON_EXISTENT') == -2 From ebb8634a76716d24dd56a6d180c19bb162473bbf Mon Sep 17 00:00:00 2001 From: naftalicy Date: Wed, 4 Dec 2024 18:42:33 +0200 Subject: [PATCH 132/257] CM-42089 - Implement Ruby restore support for SCA (#271) --- .../cli/files_collector/sca/ruby/__init__.py | 0 .../sca/ruby/restore_ruby_dependencies.py | 25 +++++++++++++++++++ .../files_collector/sca/sca_code_scanner.py | 2 ++ 3 files changed, 27 insertions(+) create mode 100644 cycode/cli/files_collector/sca/ruby/__init__.py create mode 100644 cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py diff --git a/cycode/cli/files_collector/sca/ruby/__init__.py b/cycode/cli/files_collector/sca/ruby/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py b/cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py new file mode 100644 index 00000000..3dfc4a16 --- /dev/null +++ b/cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py @@ -0,0 +1,25 @@ +import os +from typing import List, Optional + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies +from cycode.cli.models import Document + +RUBY_PROJECT_FILE_EXTENSIONS = ['Gemfile'] +RUBY_LOCK_FILE_NAME = 'Gemfile.lock' + + +class RestoreRubyDependencies(BaseRestoreDependencies): + def is_project(self, document: Document) -> bool: + return any(document.path.endswith(ext) for ext in RUBY_PROJECT_FILE_EXTENSIONS) + + def get_commands(self, manifest_file_path: str) -> List[List[str]]: + return [['bundle', '--quiet']] + + def get_lock_file_name(self) -> str: + return RUBY_LOCK_FILE_NAME + + def verify_restore_file_already_exist(self, restore_file_path: str) -> bool: + return os.path.isfile(restore_file_path) + + def get_working_directory(self, document: Document) -> Optional[str]: + return os.path.dirname(document.absolute_path) diff --git a/cycode/cli/files_collector/sca/sca_code_scanner.py b/cycode/cli/files_collector/sca/sca_code_scanner.py index d13d486c..ca6908b6 100644 --- a/cycode/cli/files_collector/sca/sca_code_scanner.py +++ b/cycode/cli/files_collector/sca/sca_code_scanner.py @@ -10,6 +10,7 @@ from cycode.cli.files_collector.sca.maven.restore_maven_dependencies import RestoreMavenDependencies from cycode.cli.files_collector.sca.npm.restore_npm_dependencies import RestoreNpmDependencies from cycode.cli.files_collector.sca.nuget.restore_nuget_dependencies import RestoreNugetDependencies +from cycode.cli.files_collector.sca.ruby.restore_ruby_dependencies import RestoreRubyDependencies from cycode.cli.files_collector.sca.sbt.restore_sbt_dependencies import RestoreSbtDependencies from cycode.cli.models import Document from cycode.cli.utils.git_proxy import git_proxy @@ -138,6 +139,7 @@ def restore_handlers(context: click.Context, is_git_diff: bool) -> List[BaseRest RestoreGoDependencies(context, is_git_diff, BUILD_DEP_TREE_TIMEOUT), RestoreNugetDependencies(context, is_git_diff, BUILD_DEP_TREE_TIMEOUT), RestoreNpmDependencies(context, is_git_diff, BUILD_DEP_TREE_TIMEOUT), + RestoreRubyDependencies(context, is_git_diff, BUILD_DEP_TREE_TIMEOUT), ] From d292487df1ca0e0805e92f9cca24c09589d3d4e6 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 11 Dec 2024 12:04:45 +0100 Subject: [PATCH 133/257] CM-42034 - Add internal AI remediations command for IDE plugins (#270) --- .github/workflows/build_executable.yml | 2 +- .../cli/commands/ai_remediation/__init__.py | 0 .../ai_remediation/ai_remediation_command.py | 67 +++++++++ cycode/cli/commands/main_cli.py | 2 + .../cli/commands/version/version_command.py | 2 +- cycode/cli/consts.py | 4 + cycode/cli/exceptions/common.py | 37 +++++ .../handle_ai_remediation_errors.py | 22 +++ .../exceptions/handle_report_sbom_errors.py | 23 +-- cycode/cli/exceptions/handle_scan_errors.py | 33 +---- cycode/cli/models.py | 2 +- cycode/cli/printers/console_printer.py | 12 ++ .../user_settings/configuration_manager.py | 7 + cycode/cyclient/models.py | 3 + cycode/cyclient/scan_client.py | 22 +++ poetry.lock | 135 +++++++++++++++--- pyproject.toml | 2 + 17 files changed, 301 insertions(+), 74 deletions(-) create mode 100644 cycode/cli/commands/ai_remediation/__init__.py create mode 100644 cycode/cli/commands/ai_remediation/ai_remediation_command.py create mode 100644 cycode/cli/exceptions/common.py create mode 100644 cycode/cli/exceptions/handle_ai_remediation_errors.py diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 4ff61810..717b18e4 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ ubuntu-20.04, macos-12, macos-14, windows-2019 ] + os: [ ubuntu-20.04, macos-13, macos-14, windows-2019 ] mode: [ 'onefile', 'onedir' ] exclude: - os: ubuntu-20.04 diff --git a/cycode/cli/commands/ai_remediation/__init__.py b/cycode/cli/commands/ai_remediation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/commands/ai_remediation/ai_remediation_command.py b/cycode/cli/commands/ai_remediation/ai_remediation_command.py new file mode 100644 index 00000000..608fc9f4 --- /dev/null +++ b/cycode/cli/commands/ai_remediation/ai_remediation_command.py @@ -0,0 +1,67 @@ +import os + +import click +from patch_ng import fromstring +from rich.console import Console +from rich.markdown import Markdown + +from cycode.cli.exceptions.handle_ai_remediation_errors import handle_ai_remediation_exception +from cycode.cli.models import CliResult +from cycode.cli.printers import ConsolePrinter +from cycode.cli.utils.get_api_client import get_scan_cycode_client + + +def _echo_remediation(context: click.Context, remediation_markdown: str, is_fix_available: bool) -> None: + printer = ConsolePrinter(context) + if printer.is_json_printer: + data = {'remediation': remediation_markdown, 'is_fix_available': is_fix_available} + printer.print_result(CliResult(success=True, message='Remediation fetched successfully', data=data)) + else: # text or table + Console().print(Markdown(remediation_markdown)) + + +def _apply_fix(context: click.Context, diff: str, is_fix_available: bool) -> None: + printer = ConsolePrinter(context) + if not is_fix_available: + printer.print_result(CliResult(success=False, message='Fix is not available for this violation')) + return + + patch = fromstring(diff.encode('UTF-8')) + if patch is False: + printer.print_result(CliResult(success=False, message='Failed to parse fix diff')) + return + + is_fix_applied = patch.apply(root=os.getcwd(), strip=0) + if is_fix_applied: + printer.print_result(CliResult(success=True, message='Fix applied successfully')) + else: + printer.print_result(CliResult(success=False, message='Failed to apply fix')) + + +@click.command(short_help='Get AI remediation (INTERNAL).', hidden=True) +@click.argument('detection_id', nargs=1, type=click.UUID, required=True) +@click.option( + '--fix', + is_flag=True, + default=False, + help='Apply fixes to resolve violations. Fix is not available for all violations.', + type=click.BOOL, + required=False, +) +@click.pass_context +def ai_remediation_command(context: click.Context, detection_id: str, fix: bool) -> None: + client = get_scan_cycode_client() + + try: + remediation_markdown = client.get_ai_remediation(detection_id) + fix_diff = client.get_ai_remediation(detection_id, fix=True) + is_fix_available = bool(fix_diff) # exclude empty string, None, etc. + + if fix: + _apply_fix(context, fix_diff, is_fix_available) + else: + _echo_remediation(context, remediation_markdown, is_fix_available) + except Exception as err: + handle_ai_remediation_exception(context, err) + + context.exit() diff --git a/cycode/cli/commands/main_cli.py b/cycode/cli/commands/main_cli.py index 67bb8171..f97e0749 100644 --- a/cycode/cli/commands/main_cli.py +++ b/cycode/cli/commands/main_cli.py @@ -3,6 +3,7 @@ import click +from cycode.cli.commands.ai_remediation.ai_remediation_command import ai_remediation_command from cycode.cli.commands.auth.auth_command import auth_command from cycode.cli.commands.configure.configure_command import configure_command from cycode.cli.commands.ignore.ignore_command import ignore_command @@ -30,6 +31,7 @@ 'auth': auth_command, 'version': version_command, 'status': status_command, + 'ai_remediation': ai_remediation_command, }, context_settings=CLI_CONTEXT_SETTINGS, ) diff --git a/cycode/cli/commands/version/version_command.py b/cycode/cli/commands/version/version_command.py index 55755e24..107aedbc 100644 --- a/cycode/cli/commands/version/version_command.py +++ b/cycode/cli/commands/version/version_command.py @@ -6,7 +6,7 @@ from cycode.cli.consts import PROGRAM_NAME -@click.command(short_help='Show the CLI version and exit.') +@click.command(short_help='Show the CLI version and exit. Use `cycode status` instead.', deprecated=True) @click.pass_context def version_command(context: click.Context) -> None: output = context.obj['output'] diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 4304580a..cd546075 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -159,6 +159,10 @@ SYNC_SCAN_TIMEOUT_IN_SECONDS_ENV_VAR_NAME = 'SYNC_SCAN_TIMEOUT_IN_SECONDS' DEFAULT_SYNC_SCAN_TIMEOUT_IN_SECONDS = 180 +# ai remediation +AI_REMEDIATION_TIMEOUT_IN_SECONDS_ENV_VAR_NAME = 'AI_REMEDIATION_TIMEOUT_IN_SECONDS' +DEFAULT_AI_REMEDIATION_TIMEOUT_IN_SECONDS = 60 + # report with polling REPORT_POLLING_WAIT_INTERVAL_IN_SECONDS = 5 DEFAULT_REPORT_POLLING_TIMEOUT_IN_SECONDS = 600 diff --git a/cycode/cli/exceptions/common.py b/cycode/cli/exceptions/common.py new file mode 100644 index 00000000..51433af7 --- /dev/null +++ b/cycode/cli/exceptions/common.py @@ -0,0 +1,37 @@ +from typing import Optional + +import click + +from cycode.cli.models import CliError, CliErrors +from cycode.cli.printers import ConsolePrinter +from cycode.cli.sentry import capture_exception + + +def handle_errors( + context: click.Context, err: BaseException, cli_errors: CliErrors, *, return_exception: bool = False +) -> Optional['CliError']: + ConsolePrinter(context).print_exception(err) + + if type(err) in cli_errors: + error = cli_errors[type(err)] + + if error.soft_fail is True: + context.obj['soft_fail'] = True + + if return_exception: + return error + + ConsolePrinter(context).print_error(error) + return None + + if isinstance(err, click.ClickException): + raise err + + capture_exception(err) + + unknown_error = CliError(code='unknown_error', message=str(err)) + if return_exception: + return unknown_error + + ConsolePrinter(context).print_error(unknown_error) + exit(1) diff --git a/cycode/cli/exceptions/handle_ai_remediation_errors.py b/cycode/cli/exceptions/handle_ai_remediation_errors.py new file mode 100644 index 00000000..ba46cbf7 --- /dev/null +++ b/cycode/cli/exceptions/handle_ai_remediation_errors.py @@ -0,0 +1,22 @@ +import click + +from cycode.cli.exceptions.common import handle_errors +from cycode.cli.exceptions.custom_exceptions import KNOWN_USER_FRIENDLY_REQUEST_ERRORS, RequestHttpError +from cycode.cli.models import CliError, CliErrors + + +class AiRemediationNotFoundError(Exception): ... + + +def handle_ai_remediation_exception(context: click.Context, err: Exception) -> None: + if isinstance(err, RequestHttpError) and err.status_code == 404: + err = AiRemediationNotFoundError() + + errors: CliErrors = { + **KNOWN_USER_FRIENDLY_REQUEST_ERRORS, + AiRemediationNotFoundError: CliError( + code='ai_remediation_not_found', + message='The AI remediation was not found. Please try different detection ID', + ), + } + handle_errors(context, err, errors) diff --git a/cycode/cli/exceptions/handle_report_sbom_errors.py b/cycode/cli/exceptions/handle_report_sbom_errors.py index bfd407a0..70cf6277 100644 --- a/cycode/cli/exceptions/handle_report_sbom_errors.py +++ b/cycode/cli/exceptions/handle_report_sbom_errors.py @@ -1,17 +1,12 @@ -from typing import Optional - import click from cycode.cli.exceptions import custom_exceptions +from cycode.cli.exceptions.common import handle_errors from cycode.cli.exceptions.custom_exceptions import KNOWN_USER_FRIENDLY_REQUEST_ERRORS 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]: - ConsolePrinter(context).print_exception() +def handle_report_exception(context: click.Context, err: Exception) -> None: errors: CliErrors = { **KNOWN_USER_FRIENDLY_REQUEST_ERRORS, custom_exceptions.ScanAsyncError: CliError( @@ -25,16 +20,4 @@ def handle_report_exception(context: click.Context, err: Exception) -> Optional[ 'Please try again by executing the `cycode report` command', ), } - - if type(err) in errors: - error = errors[type(err)] - - ConsolePrinter(context).print_error(error) - return None - - if isinstance(err, click.ClickException): - raise err - - capture_exception(err) - - raise click.ClickException(str(err)) + handle_errors(context, err, errors) diff --git a/cycode/cli/exceptions/handle_scan_errors.py b/cycode/cli/exceptions/handle_scan_errors.py index 2790418a..550e6879 100644 --- a/cycode/cli/exceptions/handle_scan_errors.py +++ b/cycode/cli/exceptions/handle_scan_errors.py @@ -3,20 +3,17 @@ import click from cycode.cli.exceptions import custom_exceptions +from cycode.cli.exceptions.common import handle_errors from cycode.cli.exceptions.custom_exceptions import KNOWN_USER_FRIENDLY_REQUEST_ERRORS 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 def handle_scan_exception( - context: click.Context, e: Exception, *, return_exception: bool = False + context: click.Context, err: Exception, *, return_exception: bool = False ) -> Optional[CliError]: context.obj['did_fail'] = True - ConsolePrinter(context).print_exception(e) - errors: CliErrors = { **KNOWN_USER_FRIENDLY_REQUEST_ERRORS, custom_exceptions.ScanAsyncError: CliError( @@ -35,7 +32,7 @@ def handle_scan_exception( custom_exceptions.TfplanKeyError: CliError( soft_fail=True, code='key_error', - message=f'\n{e!s}\n' + message=f'\n{err!s}\n' 'A crucial field is missing in your terraform plan file. ' 'Please make sure that your file is well formed ' 'and execute the scan again', @@ -48,26 +45,4 @@ def handle_scan_exception( ), } - if type(e) in errors: - error = errors[type(e)] - - if error.soft_fail is True: - context.obj['soft_fail'] = True - - if return_exception: - return error - - ConsolePrinter(context).print_error(error) - return None - - 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 - - ConsolePrinter(context).print_error(unknown_error) - exit(1) + return handle_errors(context, err, errors, return_exception=return_exception) diff --git a/cycode/cli/models.py b/cycode/cli/models.py index 39c07e44..7020ade3 100644 --- a/cycode/cli/models.py +++ b/cycode/cli/models.py @@ -63,7 +63,7 @@ class CliError(NamedTuple): soft_fail: bool = False -CliErrors = Dict[Type[Exception], CliError] +CliErrors = Dict[Type[BaseException], CliError] class CliResult(NamedTuple): diff --git a/cycode/cli/printers/console_printer.py b/cycode/cli/printers/console_printer.py index ad473560..1f70836c 100644 --- a/cycode/cli/printers/console_printer.py +++ b/cycode/cli/printers/console_printer.py @@ -60,3 +60,15 @@ def print_exception(self, e: Optional[BaseException] = None, force_print: bool = """Print traceback message in stderr if verbose mode is set.""" if force_print or self.context.obj.get('verbose', False): self._printer_class(self.context).print_exception(e) + + @property + def is_json_printer(self) -> bool: + return self._printer_class == JsonPrinter + + @property + def is_table_printer(self) -> bool: + return self._printer_class == TablePrinter + + @property + def is_text_printer(self) -> bool: + return self._printer_class == TextPrinter diff --git a/cycode/cli/user_settings/configuration_manager.py b/cycode/cli/user_settings/configuration_manager.py index 909a97f0..b83bed32 100644 --- a/cycode/cli/user_settings/configuration_manager.py +++ b/cycode/cli/user_settings/configuration_manager.py @@ -113,6 +113,13 @@ def get_sync_scan_timeout_in_seconds(self) -> int: ) ) + def get_ai_remediation_timeout_in_seconds(self) -> int: + return int( + self._get_value_from_environment_variables( + consts.AI_REMEDIATION_TIMEOUT_IN_SECONDS_ENV_VAR_NAME, consts.DEFAULT_AI_REMEDIATION_TIMEOUT_IN_SECONDS + ) + ) + def get_report_polling_timeout_in_seconds(self) -> int: return int( self._get_value_from_environment_variables( diff --git a/cycode/cyclient/models.py b/cycode/cyclient/models.py index a2162ba8..2433ef6c 100644 --- a/cycode/cyclient/models.py +++ b/cycode/cyclient/models.py @@ -13,8 +13,10 @@ def __init__( detection_details: dict, detection_rule_id: str, severity: Optional[str] = None, + id: Optional[str] = None, ) -> None: super().__init__() + self.id = id self.message = message self.type = type self.severity = severity @@ -36,6 +38,7 @@ class DetectionSchema(Schema): class Meta: unknown = EXCLUDE + id = fields.String(missing=None) message = fields.String() type = fields.String() severity = fields.String(missing=None) diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index 744d73e2..b63f49e1 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -206,6 +206,28 @@ def get_supported_modules_preferences(self) -> models.SupportedModulesPreference response = self.scan_cycode_client.get(url_path='preferences/api/v1/supportedmodules') return models.SupportedModulesPreferencesSchema().load(response.json()) + @staticmethod + def get_ai_remediation_path(detection_id: str) -> str: + return f'scm-remediator/api/v1/ContentRemediation/preview/{detection_id}' + + def get_ai_remediation(self, detection_id: str, *, fix: bool = False) -> str: + path = self.get_ai_remediation_path(detection_id) + + data = { + 'resolving_parameters': { + 'get_diff': True, + 'use_code_snippet': True, + 'add_diff_header': True, + } + } + if not fix: + data['resolving_parameters']['remediation_action'] = 'ReplyWithRemediationDetails' + + response = self.scan_cycode_client.get( + url_path=path, json=data, timeout=configuration_manager.get_ai_remediation_timeout_in_seconds() + ) + return response.text.strip() + @staticmethod def _get_policy_type_by_scan_type(scan_type: str) -> str: scan_type_to_policy_type = { diff --git a/poetry.lock b/poetry.lock index a1d8c39f..1a755b08 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "altgraph" @@ -399,6 +399,30 @@ files = [ [package.dependencies] altgraph = ">=0.17" +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + [[package]] name = "marshmallow" version = "3.22.0" @@ -418,6 +442,17 @@ dev = ["marshmallow[tests]", "pre-commit (>=3.5,<4.0)", "tox"] docs = ["alabaster (==1.0.0)", "autodocsumm (==0.2.13)", "sphinx (==8.0.2)", "sphinx-issues (==4.1.0)", "sphinx-version-warning (==1.1.2)"] tests = ["pytest", "pytz", "simplejson"] +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + [[package]] name = "mock" version = "4.0.3" @@ -436,13 +471,23 @@ test = ["pytest (<5.4)", "pytest-cov"] [[package]] name = "packaging" -version = "24.1" +version = "24.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, - {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, +] + +[[package]] +name = "patch-ng" +version = "1.18.1" +description = "Library to parse and apply unified diffs." +optional = false +python-versions = ">=3.6" +files = [ + {file = "patch-ng-1.18.1.tar.gz", hash = "sha256:52fd46ee46f6c8667692682c1fd7134edc65a2d2d084ebec1d295a6087fc0291"}, ] [[package]] @@ -482,6 +527,20 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "pygments" +version = "2.18.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, + {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + [[package]] name = "pyinstaller" version = "5.13.2" @@ -517,13 +576,13 @@ hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"] [[package]] name = "pyinstaller-hooks-contrib" -version = "2024.8" +version = "2024.10" description = "Community maintained hooks for PyInstaller" optional = false python-versions = ">=3.8" files = [ - {file = "pyinstaller_hooks_contrib-2024.8-py3-none-any.whl", hash = "sha256:0057fe9a5c398d3f580e73e58793a1d4a8315ca91c3df01efea1c14ed557825a"}, - {file = "pyinstaller_hooks_contrib-2024.8.tar.gz", hash = "sha256:29b68d878ab739e967055b56a93eb9b58e529d5b054fbab7a2f2bacf80cef3e2"}, + {file = "pyinstaller_hooks_contrib-2024.10-py3-none-any.whl", hash = "sha256:ad47db0e153683b4151e10d231cb91f2d93c85079e78d76d9e0f57ac6c8a5e10"}, + {file = "pyinstaller_hooks_contrib-2024.10.tar.gz", hash = "sha256:8a46655e5c5b0186b5e527399118a9b342f10513eb1425c483fa4f6d02e8800c"}, ] [package.dependencies] @@ -715,6 +774,25 @@ urllib3 = ">=1.25.10,<3.0" [package.extras] tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli", "tomli-w", "types-requests"] +[[package]] +name = "rich" +version = "13.9.4" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"}, + {file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" +typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.11\""} + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + [[package]] name = "ruff" version = "0.6.9" @@ -744,13 +822,13 @@ files = [ [[package]] name = "sentry-sdk" -version = "2.16.0" +version = "2.19.0" description = "Python client for Sentry (https://sentry.io)" optional = false python-versions = ">=3.6" files = [ - {file = "sentry_sdk-2.16.0-py2.py3-none-any.whl", hash = "sha256:49139c31ebcd398f4f6396b18910610a0c1602f6e67083240c33019d1f6aa30c"}, - {file = "sentry_sdk-2.16.0.tar.gz", hash = "sha256:90f733b32e15dfc1999e6b7aca67a38688a567329de4d6e184154a73f96c6892"}, + {file = "sentry_sdk-2.19.0-py2.py3-none-any.whl", hash = "sha256:7b0b3b709dee051337244a09a30dbf6e95afe0d34a1f8b430d45e0982a7c125b"}, + {file = "sentry_sdk-2.19.0.tar.gz", hash = "sha256:ee4a4d2ae8bfe3cac012dcf3e4607975904c137e1738116549fc3dbbb6ff0e36"}, ] [package.dependencies] @@ -776,14 +854,16 @@ grpcio = ["grpcio (>=1.21.1)", "protobuf (>=3.8.0)"] http2 = ["httpcore[http2] (==1.*)"] httpx = ["httpx (>=0.16.0)"] huey = ["huey (>=2)"] -huggingface-hub = ["huggingface-hub (>=0.22)"] +huggingface-hub = ["huggingface_hub (>=0.22)"] langchain = ["langchain (>=0.0.210)"] +launchdarkly = ["launchdarkly-server-sdk (>=9.8.0)"] litestar = ["litestar (>=2.0.0)"] loguru = ["loguru (>=0.5)"] openai = ["openai (>=1.0.0)", "tiktoken (>=0.3.0)"] +openfeature = ["openfeature-sdk (>=0.7.1)"] opentelemetry = ["opentelemetry-distro (>=0.35b0)"] opentelemetry-experimental = ["opentelemetry-distro"] -pure-eval = ["asttokens", "executing", "pure-eval"] +pure-eval = ["asttokens", "executing", "pure_eval"] pymongo = ["pymongo (>=3.1)"] pyspark = ["pyspark (>=2.4.4)"] quart = ["blinker (>=1.1)", "quart (>=0.16.1)"] @@ -796,23 +876,23 @@ tornado = ["tornado (>=6)"] [[package]] name = "setuptools" -version = "75.1.0" +version = "75.3.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-75.1.0-py3-none-any.whl", hash = "sha256:35ab7fd3bcd95e6b7fd704e4a1539513edad446c097797f2985e0e4b960772f2"}, - {file = "setuptools-75.1.0.tar.gz", hash = "sha256:d59a21b17a275fb872a9c3dae73963160ae079f1049ed956880cd7c09b120538"}, + {file = "setuptools-75.3.0-py3-none-any.whl", hash = "sha256:f2504966861356aa38616760c0f66568e535562374995367b4e69c7143cf6bcd"}, + {file = "setuptools-75.3.0.tar.gz", hash = "sha256:fba5dd4d766e97be1b1681d98712680ae8f2f26d7881245f2ce9e40714f1a686"}, ] [package.extras] check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"] -core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.11.*)", "pytest-mypy"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test (>=5.5)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.12.*)", "pytest-mypy"] [[package]] name = "six" @@ -849,13 +929,13 @@ files = [ [[package]] name = "tomli" -version = "2.0.2" +version = "2.1.0" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" files = [ - {file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"}, - {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"}, + {file = "tomli-2.1.0-py3-none-any.whl", hash = "sha256:a5c57c3d1c56f5ccdf89f6523458f60ef716e210fc47c4cfb188c5ba473e0391"}, + {file = "tomli-2.1.0.tar.gz", hash = "sha256:3f646cae2aec94e17d04973e4249548320197cfabdf130015d023de4b74d8ab8"}, ] [[package]] @@ -880,6 +960,17 @@ files = [ {file = "types_PyYAML-6.0.12.20240917-py3-none-any.whl", hash = "sha256:392b267f1c0fe6022952462bf5d6523f31e37f6cea49b14cee7ad634b6301570"}, ] +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + [[package]] name = "urllib3" version = "1.26.19" @@ -918,4 +1009,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = ">=3.8,<3.14" -content-hash = "6e23c9650b529e0c928f90a17d549d73b8418e11a86c2a1c9213f7582faa7e17" +content-hash = "9ad1d7ff7f6e1dc4b43af55f5f034d051dde5205cf9ac247026f8e3c2f465f31" diff --git a/pyproject.toml b/pyproject.toml index 9c1e1f9a..adb99510 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,8 @@ requests = ">=2.32.2,<3.0" urllib3 = "1.26.19" # 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" +rich = ">=13.9.4, <14" +patch-ng = "1.18.1" [tool.poetry.group.test.dependencies] mock = ">=4.0.3,<4.1.0" From 2f2759b8b177959703eb7791f42d0925dba1765b Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Thu, 12 Dec 2024 11:57:34 +0100 Subject: [PATCH 134/257] CM-42882 - Fix SCA table printing (severity weights) (#273) --- cycode/cli/commands/scan/code_scanner.py | 8 ++++---- cycode/cli/commands/scan/scan_command.py | 2 +- cycode/cli/models.py | 12 ++++++++---- cycode/cli/printers/tables/sca_table_printer.py | 7 +++++-- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/cycode/cli/commands/scan/code_scanner.py b/cycode/cli/commands/scan/code_scanner.py index 47d017b0..59e99900 100644 --- a/cycode/cli/commands/scan/code_scanner.py +++ b/cycode/cli/commands/scan/code_scanner.py @@ -627,7 +627,10 @@ def get_document_detections( def exclude_irrelevant_document_detections( - document_detections_list: List[DocumentDetections], scan_type: str, command_scan_type: str, severity_threshold: str + document_detections_list: List[DocumentDetections], + scan_type: str, + command_scan_type: str, + severity_threshold: str, ) -> List[DocumentDetections]: relevant_document_detections_list = [] for document_detections in document_detections_list: @@ -717,9 +720,6 @@ def exclude_irrelevant_detections( def _exclude_detections_by_severity(detections: List[Detection], severity_threshold: str) -> List[Detection]: - if severity_threshold is None: - return detections - relevant_detections = [] for detection in detections: severity = detection.detection_details.get('advisory_severity') diff --git a/cycode/cli/commands/scan/scan_command.py b/cycode/cli/commands/scan/scan_command.py index a428a87a..5282dfb7 100644 --- a/cycode/cli/commands/scan/scan_command.py +++ b/cycode/cli/commands/scan/scan_command.py @@ -65,7 +65,7 @@ ) @click.option( '--severity-threshold', - default=None, + default=Severity.INFO.name, help='Show violations only for the specified level or higher.', type=click.Choice([e.name for e in Severity]), required=False, diff --git a/cycode/cli/models.py b/cycode/cli/models.py index 7020ade3..25b2347f 100644 --- a/cycode/cli/models.py +++ b/cycode/cli/models.py @@ -33,6 +33,9 @@ def __repr__(self) -> str: return 'document:{0}, detections:{1}'.format(self.document, self.detections) +SEVERITY_UNKNOWN_WEIGHT = -2 + + class Severity(Enum): INFO = -1 LOW = 0 @@ -42,7 +45,7 @@ class Severity(Enum): CRITICAL = 3 @staticmethod - def try_get_value(name: str) -> any: + def try_get_value(name: str) -> Optional[int]: name = name.upper() if name not in Severity.__members__: return None @@ -50,10 +53,11 @@ def try_get_value(name: str) -> any: return Severity[name].value @staticmethod - def get_member_weight(name: str) -> any: + def get_member_weight(name: str) -> int: weight = Severity.try_get_value(name) - if weight is None: # if License Compliance - return -2 + if weight is None: # unknown severity + return SEVERITY_UNKNOWN_WEIGHT + return weight diff --git a/cycode/cli/printers/tables/sca_table_printer.py b/cycode/cli/printers/tables/sca_table_printer.py index d51359a3..5a6ec726 100644 --- a/cycode/cli/printers/tables/sca_table_printer.py +++ b/cycode/cli/printers/tables/sca_table_printer.py @@ -4,7 +4,7 @@ import click from cycode.cli.consts import LICENSE_COMPLIANCE_POLICY_ID, PACKAGE_VULNERABILITY_POLICY_ID -from cycode.cli.models import Detection, Severity +from cycode.cli.models import SEVERITY_UNKNOWN_WEIGHT, Detection, Severity from cycode.cli.printers.tables.table import Table from cycode.cli.printers.tables.table_models import ColumnInfoBuilder, ColumnWidths from cycode.cli.printers.tables.table_printer_base import TablePrinterBase @@ -73,7 +73,10 @@ def __group_by(detections: List[Detection], details_field_name: str) -> Dict[str @staticmethod def __severity_sort_key(detection: Detection) -> int: severity = detection.detection_details.get('advisory_severity') - return Severity.get_member_weight(severity) + if severity: + return Severity.get_member_weight(severity) + + return SEVERITY_UNKNOWN_WEIGHT def _sort_detections_by_severity(self, detections: List[Detection]) -> List[Detection]: return sorted(detections, key=self.__severity_sort_key, reverse=True) From aa534b387314b31c98e139ddff9d18b7c3ca0a96 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Fri, 13 Dec 2024 17:55:49 +0100 Subject: [PATCH 135/257] CM-42771 - Add support of `.gitignore` files for a file excluding from scans (#272) --- .github/workflows/tests.yml | 2 +- .github/workflows/tests_full.yml | 4 +- cycode/cli/files_collector/path_documents.py | 26 +- cycode/cli/files_collector/walk_ignore.py | 42 ++ cycode/cli/utils/ignore_utils.py | 459 ++++++++++++++++++ poetry.lock | 80 ++- pyproject.toml | 2 +- tests/cli/files_collector/__init__.py | 0 tests/cli/files_collector/test_walk_ignore.py | 142 ++++++ tests/utils/test_ignore_utils.py | 176 +++++++ 10 files changed, 887 insertions(+), 46 deletions(-) create mode 100644 cycode/cli/files_collector/walk_ignore.py create mode 100644 cycode/cli/utils/ignore_utils.py create mode 100644 tests/cli/files_collector/__init__.py create mode 100644 tests/cli/files_collector/test_walk_ignore.py create mode 100644 tests/utils/test_ignore_utils.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7c4a2b47..0b5ddb58 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -50,4 +50,4 @@ jobs: run: poetry install - name: Run Tests - run: poetry run pytest + run: poetry run python -m pytest diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index 8181b586..a760d617 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -50,7 +50,7 @@ jobs: uses: actions/cache@v3 with: path: ~/.local - key: poetry-${{ matrix.os }}-${{ matrix.python-version }}-1 # increment to reset cache + key: poetry-${{ matrix.os }}-${{ matrix.python-version }}-2 # increment to reset cache - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' @@ -71,4 +71,4 @@ jobs: ./dist/cycode-cli version - name: Run pytest - run: poetry run pytest + run: poetry run python -m pytest diff --git a/cycode/cli/files_collector/path_documents.py b/cycode/cli/files_collector/path_documents.py index 98a021e4..14f88888 100644 --- a/cycode/cli/files_collector/path_documents.py +++ b/cycode/cli/files_collector/path_documents.py @@ -1,7 +1,5 @@ import os -from typing import TYPE_CHECKING, Iterable, List, Tuple - -import pathspec +from typing import TYPE_CHECKING, List, Tuple from cycode.cli.files_collector.excluder import exclude_irrelevant_files from cycode.cli.files_collector.iac.tf_content_generator import ( @@ -10,6 +8,7 @@ is_iac, is_tfplan_file, ) +from cycode.cli.files_collector.walk_ignore import walk_ignore from cycode.cli.models import Document from cycode.cli.utils.path_utils import get_absolute_path, get_file_content from cycode.cyclient import logger @@ -18,17 +17,18 @@ from cycode.cli.utils.progress_bar import BaseProgressBar, ProgressBarSection -def _get_all_existing_files_in_directory(path: str) -> List[str]: +def _get_all_existing_files_in_directory(path: str, *, walk_with_ignore_patterns: bool = True) -> List[str]: files: List[str] = [] - for root, _, filenames in os.walk(path): + walk_func = walk_ignore if walk_with_ignore_patterns else os.walk + for root, _, filenames in walk_func(path): for filename in filenames: files.append(os.path.join(root, filename)) return files -def _get_relevant_files_in_path(path: str, exclude_patterns: Iterable[str]) -> List[str]: +def _get_relevant_files_in_path(path: str) -> List[str]: absolute_path = get_absolute_path(path) if not os.path.isfile(absolute_path) and not os.path.isdir(absolute_path): @@ -37,14 +37,8 @@ def _get_relevant_files_in_path(path: str, exclude_patterns: Iterable[str]) -> L if os.path.isfile(absolute_path): return [absolute_path] - all_file_paths = set(_get_all_existing_files_in_directory(absolute_path)) - - path_spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, exclude_patterns) - excluded_file_paths = set(path_spec.match_files(all_file_paths)) - - relevant_file_paths = all_file_paths - excluded_file_paths - - return [file_path for file_path in relevant_file_paths if os.path.isfile(file_path)] + file_paths = _get_all_existing_files_in_directory(absolute_path) + return [file_path for file_path in file_paths if os.path.isfile(file_path)] def _get_relevant_files( @@ -52,9 +46,7 @@ def _get_relevant_files( ) -> List[str]: all_files_to_scan = [] for path in paths: - all_files_to_scan.extend( - _get_relevant_files_in_path(path=path, exclude_patterns=['**/.git/**', '**/.cycode/**']) - ) + all_files_to_scan.extend(_get_relevant_files_in_path(path)) # we are double the progress bar section length because we are going to process the files twice # first time to get the file list with respect of excluded patterns (excluding takes seconds to execute) diff --git a/cycode/cli/files_collector/walk_ignore.py b/cycode/cli/files_collector/walk_ignore.py new file mode 100644 index 00000000..76d04366 --- /dev/null +++ b/cycode/cli/files_collector/walk_ignore.py @@ -0,0 +1,42 @@ +import os +from typing import Generator, Iterable, List, Tuple + +from cycode.cli.utils.ignore_utils import IgnoreFilterManager +from cycode.cyclient import logger + +_SUPPORTED_IGNORE_PATTERN_FILES = { # oneday we will bring .cycodeignore or something like that + '.gitignore', +} +_DEFAULT_GLOBAL_IGNORE_PATTERNS = [ + '.git', + '.cycode', +] + + +def _walk_to_top(path: str) -> Iterable[str]: + while os.path.dirname(path) != path: + yield path + path = os.path.dirname(path) + + if path: + yield path # Include the top-level directory + + +def _collect_top_level_ignore_files(path: str) -> List[str]: + ignore_files = [] + for dir_path in _walk_to_top(path): + for ignore_file in _SUPPORTED_IGNORE_PATTERN_FILES: + ignore_file_path = os.path.join(dir_path, ignore_file) + if os.path.exists(ignore_file_path): + logger.debug('Apply top level ignore file: %s', ignore_file_path) + ignore_files.append(ignore_file_path) + return ignore_files + + +def walk_ignore(path: str) -> Generator[Tuple[str, List[str], List[str]], None, None]: + ignore_filter_manager = IgnoreFilterManager.build( + path=path, + global_ignore_file_paths=_collect_top_level_ignore_files(path), + global_patterns=_DEFAULT_GLOBAL_IGNORE_PATTERNS, + ) + yield from ignore_filter_manager.walk() diff --git a/cycode/cli/utils/ignore_utils.py b/cycode/cli/utils/ignore_utils.py new file mode 100644 index 00000000..329fa055 --- /dev/null +++ b/cycode/cli/utils/ignore_utils.py @@ -0,0 +1,459 @@ +# Copyright (C) 2017 Jelmer Vernooij +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Modified from https://github.com/jelmer/dulwich/blob/master/dulwich/ignore.py + +# Copyright 2020 Ben Kehoe +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Modified from https://github.com/benkehoe/ignorelib/blob/main/ignorelib.py + +"""Parsing of ignore files according to gitignore rules. + +For details for the matching rules, see https://git-scm.com/docs/gitignore +""" + +import contextlib +import os.path +import re +from os import PathLike +from typing import ( + Any, + BinaryIO, + Dict, + Generator, + Iterable, + List, + Optional, + Tuple, + Union, +) + + +def _translate_segment(segment: bytes) -> bytes: # noqa: C901 + if segment == b'*': + return b'[^/]+' + res = b'' + i, n = 0, len(segment) + while i < n: + c = segment[i : i + 1] + i = i + 1 + if c == b'*': + res += b'[^/]*' + elif c == b'?': + res += b'[^/]' + elif c == b'\\': + res += re.escape(segment[i : i + 1]) + i += 1 + elif c == b'[': + j = i + if j < n and segment[j : j + 1] == b'!': + j = j + 1 + if j < n and segment[j : j + 1] == b']': + j = j + 1 + while j < n and segment[j : j + 1] != b']': + j = j + 1 + if j >= n: + res += b'\\[' + else: + stuff = segment[i:j].replace(b'\\', b'\\\\') + i = j + 1 + if stuff.startswith(b'!'): + stuff = b'^' + stuff[1:] + elif stuff.startswith(b'^'): + stuff = b'\\' + stuff + res += b'[' + stuff + b']' + else: + res += re.escape(c) + return res + + +def translate(pat: bytes) -> bytes: + """Translate a shell PATTERN to a regular expression. + + There is no way to quote meta-characters. + + Originally copied from fnmatch in Python 2.7, but modified for Dulwich + to cope with features in Git ignore patterns. + """ + + res = b'(?ms)' + + if b'/' not in pat[:-1]: + # If there's no slash, this is a filename-based match + res += b'(.*/)?' + + if pat.startswith(b'**/'): + # Leading **/ + pat = pat[2:] + res += b'(.*/)?' + + if pat.startswith(b'/'): + pat = pat[1:] + + for i, segment in enumerate(pat.split(b'/')): + if segment == b'**': + res += b'(/.*)?' + continue + res += (re.escape(b'/') if i > 0 else b'') + _translate_segment(segment) + + if not pat.endswith(b'/'): + res += b'/?' + + return res + b'\\Z' + + +def read_ignore_patterns(f: BinaryIO) -> Iterable[bytes]: + """Read a git ignore file. + + Args: + f: File-like object to read from + Returns: List of patterns + """ + for line in f: + line = line.rstrip(b'\r\n') + + # Ignore blank lines, they're used for readability. + if not line.strip(): + continue + + if line.startswith(b'#'): + # Comment + continue + + # Trailing spaces are ignored unless they are quoted with a backslash. + while line.endswith(b' ') and not line.endswith(b'\\ '): + line = line[:-1] + line = line.replace(b'\\ ', b' ') + + yield line + + +def match_pattern(path: bytes, pattern: bytes, ignore_case: bool = False) -> bool: + """Match a gitignore-style pattern against a path. + + Args: + path: Path to match + pattern: Pattern to match + ignore_case: Whether to do case-sensitive matching + Returns: + bool indicating whether the pattern matched + """ + return Pattern(pattern, ignore_case).match(path) + + +class Pattern: + """A single ignore pattern.""" + + def __init__(self, pattern: bytes, ignore_case: bool = False) -> None: + self.pattern = pattern + self.ignore_case = ignore_case + if pattern[0:1] == b'!': + self.is_exclude = False + pattern = pattern[1:] + else: + if pattern[0:1] == b'\\': + pattern = pattern[1:] + self.is_exclude = True + flags = 0 + if self.ignore_case: + flags = re.IGNORECASE + self._re = re.compile(translate(pattern), flags) + + def __bytes__(self) -> bytes: + return self.pattern + + def __str__(self) -> str: + return os.fsdecode(self.pattern) + + def __eq__(self, other: object) -> bool: + return isinstance(other, type(self)) and self.pattern == other.pattern and self.ignore_case == other.ignore_case + + def __repr__(self) -> str: + return f'{type(self).__name__}({self.pattern!r}, {self.ignore_case!r})' + + def match(self, path: bytes) -> bool: + """Try to match a path against this ignore pattern. + + Args: + path: Path to match (relative to ignore location) + Returns: boolean + """ + return bool(self._re.match(path)) + + +class IgnoreFilter: + def __init__( + self, + patterns: Iterable[Union[str, bytes]], + ignore_case: bool = False, + path: Optional[Union[PathLike, str]] = None, + ) -> None: + if hasattr(path, '__fspath__'): + path = path.__fspath__() + self._patterns = [] # type: List[Pattern] + self._ignore_case = ignore_case + self._path = path + for pattern in patterns: + self.append_pattern(pattern) + + def to_dict(self) -> Dict[str, Any]: + d = { + 'patterns': [str(p) for p in self._patterns], + 'ignore_case': self._ignore_case, + } + path = getattr(self, '_path', None) + if path: + d['path'] = path + return d + + def append_pattern(self, pattern: Union[str, bytes]) -> None: + """Add a pattern to the set.""" + if isinstance(pattern, str): + pattern = bytes(pattern, 'utf-8') + self._patterns.append(Pattern(pattern, self._ignore_case)) + + def find_matching(self, path: Union[bytes, str]) -> Iterable[Pattern]: + """Yield all matching patterns for path. + + Args: + path: Path to match + Returns: + Iterator over iterators + """ + if not isinstance(path, bytes): + path = os.fsencode(path) + for pattern in self._patterns: + if pattern.match(path): + yield pattern + + def is_ignored(self, path: Union[bytes, str]) -> Optional[bool]: + """Check whether a path is ignored. + + For directories, include a trailing slash. + + Returns: status is None if file is not mentioned, True if it is + included, False if it is explicitly excluded. + """ + if hasattr(path, '__fspath__'): + path = path.__fspath__() + status = None + for pattern in self.find_matching(path): + status = pattern.is_exclude + return status + + @classmethod + def from_path(cls, path: Union[PathLike, str], ignore_case: bool = False) -> 'IgnoreFilter': + if hasattr(path, '__fspath__'): + path = path.__fspath__() + with open(path, 'rb') as f: + return cls(read_ignore_patterns(f), ignore_case, path=path) + + def __repr__(self) -> str: + path = getattr(self, '_path', None) + if path is not None: + return f'{type(self).__name__}.from_path({path!r})' + return f'<{type(self).__name__}>' + + +class IgnoreFilterManager: + """Ignore file manager.""" + + def __init__( + self, + path: str, + global_filters: List[IgnoreFilter], + ignore_file_name: Optional[str] = None, + ignore_case: bool = False, + ) -> None: + if hasattr(path, '__fspath__'): + path = path.__fspath__() + self._path_filters = {} # type: Dict[str, Optional[IgnoreFilter]] + self._top_path = path + self._global_filters = global_filters + + self._ignore_file_name = ignore_file_name + if self._ignore_file_name is None: + self._ignore_file_name = '.gitignore' + + self._ignore_case = ignore_case + + def __repr__(self) -> str: + return f'{type(self).__name__}({self._top_path}, {self._global_filters!r}, {self._ignore_case!r})' + + def to_dict(self, include_path_filters: bool = True) -> Dict[str, Any]: + d = { + 'path': self._top_path, + 'global_filters': [f.to_dict() for f in self._global_filters], + 'ignore_case': self._ignore_case, + } + if include_path_filters: + d['path_filters'] = {path: f.to_dict() for path, f in self._path_filters.items() if f is not None} + return d + + @property + def path(self) -> str: + return self._top_path + + @property + def ignore_file_name(self) -> Optional[str]: + return self._ignore_file_name + + @property + def ignore_case(self) -> bool: + return self._ignore_case + + def _load_path(self, path: str) -> Optional[IgnoreFilter]: + try: + return self._path_filters[path] + except KeyError: + pass + + if not self._ignore_file_name: + self._path_filters[path] = None + else: + p = os.path.join(self._top_path, path, self._ignore_file_name) + try: + self._path_filters[path] = IgnoreFilter.from_path(p, self._ignore_case) + except IOError: + self._path_filters[path] = None + return self._path_filters[path] + + def _find_matching(self, path: str) -> Iterable[Pattern]: + """Find matching patterns for path. + + Args: + path: Path to check + Returns: + Iterator over Pattern instances + """ + if os.path.isabs(path): + raise ValueError(f'{path} is an absolute path') + filters = [(0, f) for f in self._global_filters] + if os.path.sep != '/': + path = path.replace(os.path.sep, '/') + parts = path.split('/') + matches = [] + for i in range(len(parts) + 1): + dirname = '/'.join(parts[:i]) + for s, f in filters: + relpath = '/'.join(parts[s:i]) + if i < len(parts): + # Paths leading up to the final part are all directories, + # so need a trailing slash. + relpath += '/' + matches += list(f.find_matching(relpath)) + ignore_filter = self._load_path(dirname) + if ignore_filter is not None: + filters.insert(0, (i, ignore_filter)) + return iter(matches) + + def is_ignored(self, path: str) -> Optional[bool]: + """Check whether a path is ignored. + + Args: + path: Path to check, relative to the IgnoreFilterManager path + Returns: + True if the path matches an ignore pattern, + False if the path is explicitly not ignored, + or None if the file does not match any patterns. + """ + if hasattr(path, '__fspath__'): + path = path.__fspath__() + matches = list(self._find_matching(path)) + if matches: + return matches[-1].is_exclude + return None + + def walk(self, **kwargs) -> Generator[Tuple[str, List[str], List[str]], None, None]: + """A wrapper for os.walk() without ignored files and subdirectories. + kwargs are passed to walk().""" + + for dirpath, dirnames, filenames in os.walk(self.path, topdown=True, **kwargs): + rel_dirpath = '' if dirpath == self.path else os.path.relpath(dirpath, self.path) + + # decrease recursion depth of os.walk() by ignoring subdirectories because of topdown=True + # slicing ([:]) is mandatory to change dict in-place! + dirnames[:] = [ + dirname for dirname in dirnames if not self.is_ignored(os.path.join(rel_dirpath, dirname, '')) + ] + + # remove ignored files + filenames = [os.path.basename(f) for f in filenames if not self.is_ignored(os.path.join(rel_dirpath, f))] + + yield dirpath, dirnames, filenames + + @classmethod + def build( + cls, + path: str, + global_ignore_file_paths: Optional[Iterable[str]] = None, + global_patterns: Optional[Iterable[Union[str, bytes]]] = None, + ignore_file_name: Optional[str] = None, + ignore_case: bool = False, + ) -> 'IgnoreFilterManager': + """Create a IgnoreFilterManager from patterns and paths. + Args: + path: The root path for ignore checks. + global_ignore_file_paths: A list of file paths to load patterns from. + Relative paths are relative to the IgnoreFilterManager path, not + the current directory. + global_patterns: Global patterns to ignore. + ignore_file_name: The per-directory ignore file name. + ignore_case: Whether to ignore case in matching. + Returns: + A `IgnoreFilterManager` object + """ + if not global_ignore_file_paths: + global_ignore_file_paths = [] + if not global_patterns: + global_patterns = [] + + global_ignore_file_paths.extend( + [ + os.path.join('.git', 'info', 'exclude'), # relative to an input path, so within the repo + os.path.expanduser(os.path.join('~', '.config', 'git', 'ignore')), # absolute + ] + ) + + if hasattr(path, '__fspath__'): + path = path.__fspath__() + + global_filters = [] + for p in global_ignore_file_paths: + if hasattr(p, '__fspath__'): + p = p.__fspath__() + + p = os.path.expanduser(p) + if not os.path.isabs(p): + p = os.path.join(path, p) + + with contextlib.suppress(IOError): + global_filters.append(IgnoreFilter.from_path(p)) + + if global_patterns: + global_filters.append(IgnoreFilter(global_patterns)) + + return cls(path, global_filters=global_filters, ignore_file_name=ignore_file_name, ignore_case=ignore_case) diff --git a/poetry.lock b/poetry.lock index 1a755b08..c97b44a9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -490,17 +490,6 @@ files = [ {file = "patch-ng-1.18.1.tar.gz", hash = "sha256:52fd46ee46f6c8667692682c1fd7134edc65a2d2d084ebec1d295a6087fc0291"}, ] -[[package]] -name = "pathspec" -version = "0.12.1" -description = "Utility library for gitignore style pattern matching of file paths." -optional = false -python-versions = ">=3.8" -files = [ - {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, - {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, -] - [[package]] name = "pefile" version = "2024.8.26" @@ -527,6 +516,17 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "pyfakefs" +version = "5.7.2" +description = "pyfakefs implements a fake file system that mocks the Python file system modules." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pyfakefs-5.7.2-py3-none-any.whl", hash = "sha256:e1527b0e8e4b33be52f0b024ca1deb269c73eecd68457c6b0bf608d6dab12ebd"}, + {file = "pyfakefs-5.7.2.tar.gz", hash = "sha256:40da84175c5af8d9c4f3b31800b8edc4af1e74a212671dd658b21cc881c60000"}, +] + [[package]] name = "pygments" version = "2.18.0" @@ -822,13 +822,13 @@ files = [ [[package]] name = "sentry-sdk" -version = "2.19.0" +version = "2.19.2" description = "Python client for Sentry (https://sentry.io)" optional = false python-versions = ">=3.6" files = [ - {file = "sentry_sdk-2.19.0-py2.py3-none-any.whl", hash = "sha256:7b0b3b709dee051337244a09a30dbf6e95afe0d34a1f8b430d45e0982a7c125b"}, - {file = "sentry_sdk-2.19.0.tar.gz", hash = "sha256:ee4a4d2ae8bfe3cac012dcf3e4607975904c137e1738116549fc3dbbb6ff0e36"}, + {file = "sentry_sdk-2.19.2-py2.py3-none-any.whl", hash = "sha256:ebdc08228b4d131128e568d696c210d846e5b9d70aa0327dec6b1272d9d40b84"}, + {file = "sentry_sdk-2.19.2.tar.gz", hash = "sha256:467df6e126ba242d39952375dd816fbee0f217d119bf454a8ce74cf1e7909e8d"}, ] [package.dependencies] @@ -896,13 +896,13 @@ type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.12 [[package]] name = "six" -version = "1.16.0" +version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, ] [[package]] @@ -929,24 +929,54 @@ files = [ [[package]] name = "tomli" -version = "2.1.0" +version = "2.2.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" files = [ - {file = "tomli-2.1.0-py3-none-any.whl", hash = "sha256:a5c57c3d1c56f5ccdf89f6523458f60ef716e210fc47c4cfb188c5ba473e0391"}, - {file = "tomli-2.1.0.tar.gz", hash = "sha256:3f646cae2aec94e17d04973e4249548320197cfabdf130015d023de4b74d8ab8"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, + {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, + {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, + {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, + {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, + {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, + {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, + {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, + {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, ] [[package]] name = "types-python-dateutil" -version = "2.9.0.20241003" +version = "2.9.0.20241206" description = "Typing stubs for python-dateutil" optional = false python-versions = ">=3.8" files = [ - {file = "types-python-dateutil-2.9.0.20241003.tar.gz", hash = "sha256:58cb85449b2a56d6684e41aeefb4c4280631246a0da1a719bdbe6f3fb0317446"}, - {file = "types_python_dateutil-2.9.0.20241003-py3-none-any.whl", hash = "sha256:250e1d8e80e7bbc3a6c99b907762711d1a1cdd00e978ad39cb5940f6f0a87f3d"}, + {file = "types_python_dateutil-2.9.0.20241206-py3-none-any.whl", hash = "sha256:e248a4bc70a486d3e3ec84d0dc30eec3a5f979d6e7ee4123ae043eedbb987f53"}, + {file = "types_python_dateutil-2.9.0.20241206.tar.gz", hash = "sha256:18f493414c26ffba692a72369fea7a154c502646301ebfe3d56a04b3767284cb"}, ] [[package]] @@ -1009,4 +1039,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = ">=3.8,<3.14" -content-hash = "9ad1d7ff7f6e1dc4b43af55f5f034d051dde5205cf9ac247026f8e3c2f465f31" +content-hash = "e91a6f9b7e080cea351f9073ef333afe026df6172b95fba5477af67f15c96000" diff --git a/pyproject.toml b/pyproject.toml index adb99510..42511ec8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,6 @@ click = ">=8.1.0,<8.2.0" colorama = ">=0.4.3,<0.5.0" pyyaml = ">=6.0,<7.0" marshmallow = ">=3.15.0,<3.23.0" # 3.23 dropped support for Python 3.8 -pathspec = ">=0.11.1,<0.13.0" gitpython = ">=3.1.30,<3.2.0" arrow = ">=1.0.0,<1.4.0" binaryornot = ">=0.4.4,<0.5.0" @@ -50,6 +49,7 @@ pytest = ">=7.3.1,<7.4.0" pytest-mock = ">=3.10.0,<3.11.0" coverage = ">=7.2.3,<7.3.0" responses = ">=0.23.1,<0.24.0" +pyfakefs = ">=5.7.2,<5.8.0" [tool.poetry.group.executable.dependencies] pyinstaller = {version=">=5.13.2,<5.14.0", python=">=3.8,<3.13"} diff --git a/tests/cli/files_collector/__init__.py b/tests/cli/files_collector/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/test_walk_ignore.py b/tests/cli/files_collector/test_walk_ignore.py new file mode 100644 index 00000000..fd2612d5 --- /dev/null +++ b/tests/cli/files_collector/test_walk_ignore.py @@ -0,0 +1,142 @@ +import os +from os.path import normpath +from typing import TYPE_CHECKING, List + +from cycode.cli.files_collector.walk_ignore import ( + _collect_top_level_ignore_files, + _walk_to_top, + walk_ignore, +) + +if TYPE_CHECKING: + from pyfakefs.fake_filesystem import FakeFilesystem + + +# we are using normpath() in every test to provide multi-platform support + + +def test_walk_to_top() -> None: + path = normpath('/a/b/c/d/e/f/g') + result = list(_walk_to_top(path)) + assert result == [ + normpath('/a/b/c/d/e/f/g'), + normpath('/a/b/c/d/e/f'), + normpath('/a/b/c/d/e'), + normpath('/a/b/c/d'), + normpath('/a/b/c'), + normpath('/a/b'), + normpath('/a'), + normpath('/'), + ] + + path = normpath('/a') + result = list(_walk_to_top(path)) + assert result == [normpath('/a'), normpath('/')] + + path = normpath('/') + result = list(_walk_to_top(path)) + assert result == [normpath('/')] + + path = normpath('a') + result = list(_walk_to_top(path)) + assert result == [normpath('a')] + + +def _create_mocked_file_structure(fs: 'FakeFilesystem') -> None: + fs.create_dir('/home/user/project') + fs.create_dir('/home/user/.git') + + fs.create_dir('/home/user/project/.cycode') + fs.create_file('/home/user/project/.cycode/config.yaml') + fs.create_dir('/home/user/project/.git') + fs.create_file('/home/user/project/.git/HEAD') + + fs.create_file('/home/user/project/.gitignore', contents='*.pyc\n*.log') + fs.create_file('/home/user/project/ignored.pyc') + fs.create_file('/home/user/project/presented.txt') + fs.create_file('/home/user/project/ignored2.log') + fs.create_file('/home/user/project/ignored2.pyc') + fs.create_file('/home/user/project/presented2.txt') + + fs.create_dir('/home/user/project/subproject') + fs.create_file('/home/user/project/subproject/.gitignore', contents='*.txt') + fs.create_file('/home/user/project/subproject/ignored.txt') + fs.create_file('/home/user/project/subproject/ignored.log') + fs.create_file('/home/user/project/subproject/ignored.pyc') + fs.create_file('/home/user/project/subproject/presented.py') + + +def test_collect_top_level_ignore_files(fs: 'FakeFilesystem') -> None: + _create_mocked_file_structure(fs) + + # Test with path inside the project + path = normpath('/home/user/project/subproject') + ignore_files = _collect_top_level_ignore_files(path) + assert len(ignore_files) == 2 + assert normpath('/home/user/project/subproject/.gitignore') in ignore_files + assert normpath('/home/user/project/.gitignore') in ignore_files + + # Test with path at the top level with no ignore files + path = normpath('/home/user/.git') + ignore_files = _collect_top_level_ignore_files(path) + assert len(ignore_files) == 0 + + # Test with path at the top level with a .gitignore + path = normpath('/home/user/project') + ignore_files = _collect_top_level_ignore_files(path) + assert len(ignore_files) == 1 + assert normpath('/home/user/project/.gitignore') in ignore_files + + # Test with a path that does not have any ignore files + fs.remove('/home/user/project/.gitignore') + path = normpath('/home/user') + ignore_files = _collect_top_level_ignore_files(path) + assert len(ignore_files) == 0 + fs.create_file('/home/user/project/.gitignore', contents='*.pyc\n*.log') + + +def _collect_walk_ignore_files(path: str) -> List[str]: + files = [] + for root, _, filenames in walk_ignore(path): + for filename in filenames: + files.append(os.path.join(root, filename)) + + return files + + +def test_walk_ignore(fs: 'FakeFilesystem') -> None: + _create_mocked_file_structure(fs) + + path = normpath('/home/user/project') + result = _collect_walk_ignore_files(path) + + assert len(result) == 5 + # ignored globally by default: + assert normpath('/home/user/project/.git/HEAD') not in result + assert normpath('/home/user/project/.cycode/config.yaml') not in result + # ignored by .gitignore in project directory: + assert normpath('/home/user/project/ignored.pyc') not in result + assert normpath('/home/user/project/subproject/ignored.pyc') not in result + # ignored by .gitignore in subproject directory: + assert normpath('/home/user/project/subproject/ignored.txt') not in result + # ignored by .cycodeignore in project directory: + assert normpath('/home/user/project/ignored2.log') not in result + assert normpath('/home/user/project/ignored2.pyc') not in result + assert normpath('/home/user/project/subproject/ignored.log') not in result + # presented after both .gitignore and .cycodeignore: + assert normpath('/home/user/project/.gitignore') in result + assert normpath('/home/user/project/subproject/.gitignore') in result + assert normpath('/home/user/project/presented.txt') in result + assert normpath('/home/user/project/presented2.txt') in result + assert normpath('/home/user/project/subproject/presented.py') in result + + path = normpath('/home/user/project/subproject') + result = _collect_walk_ignore_files(path) + + assert len(result) == 2 + # ignored: + assert normpath('/home/user/project/subproject/ignored.txt') not in result + assert normpath('/home/user/project/subproject/ignored.log') not in result + assert normpath('/home/user/project/subproject/ignored.pyc') not in result + # presented: + assert normpath('/home/user/project/subproject/presented.py') in result diff --git a/tests/utils/test_ignore_utils.py b/tests/utils/test_ignore_utils.py new file mode 100644 index 00000000..563c11a9 --- /dev/null +++ b/tests/utils/test_ignore_utils.py @@ -0,0 +1,176 @@ +# Copyright (C) 2017 Jelmer Vernooij +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Modified (rewritten to pytest + pyfakefs) from https://github.com/jelmer/dulwich/blob/master/tests/test_ignore.py + +import os +import re +from io import BytesIO +from typing import TYPE_CHECKING + +import pytest + +from cycode.cli.utils.ignore_utils import ( + IgnoreFilter, + IgnoreFilterManager, + Pattern, + match_pattern, + read_ignore_patterns, + translate, +) + +if TYPE_CHECKING: + from pyfakefs.fake_filesystem import FakeFilesystem + +POSITIVE_MATCH_TESTS = [ + (b'foo.c', b'*.c'), + (b'.c', b'*.c'), + (b'foo/foo.c', b'*.c'), + (b'foo/foo.c', b'foo.c'), + (b'foo.c', b'/*.c'), + (b'foo.c', b'/foo.c'), + (b'foo.c', b'foo.c'), + (b'foo.c', b'foo.[ch]'), + (b'foo/bar/bla.c', b'foo/**'), + (b'foo/bar/bla/blie.c', b'foo/**/blie.c'), + (b'foo/bar/bla.c', b'**/bla.c'), + (b'bla.c', b'**/bla.c'), + (b'foo/bar', b'foo/**/bar'), + (b'foo/bla/bar', b'foo/**/bar'), + (b'foo/bar/', b'bar/'), + (b'foo/bar/', b'bar'), + (b'foo/bar/something', b'foo/bar/*'), +] + +NEGATIVE_MATCH_TESTS = [ + (b'foo.c', b'foo.[dh]'), + (b'foo/foo.c', b'/foo.c'), + (b'foo/foo.c', b'/*.c'), + (b'foo/bar/', b'/bar/'), + (b'foo/bar/', b'foo/bar/*'), + (b'foo/bar', b'foo?bar'), +] + +TRANSLATE_TESTS = [ + (b'*.c', b'(?ms)(.*/)?[^/]*\\.c/?\\Z'), + (b'foo.c', b'(?ms)(.*/)?foo\\.c/?\\Z'), + (b'/*.c', b'(?ms)[^/]*\\.c/?\\Z'), + (b'/foo.c', b'(?ms)foo\\.c/?\\Z'), + (b'foo.c', b'(?ms)(.*/)?foo\\.c/?\\Z'), + (b'foo.[ch]', b'(?ms)(.*/)?foo\\.[ch]/?\\Z'), + (b'bar/', b'(?ms)(.*/)?bar\\/\\Z'), + (b'foo/**', b'(?ms)foo(/.*)?/?\\Z'), + (b'foo/**/blie.c', b'(?ms)foo(/.*)?\\/blie\\.c/?\\Z'), + (b'**/bla.c', b'(?ms)(.*/)?bla\\.c/?\\Z'), + (b'foo/**/bar', b'(?ms)foo(/.*)?\\/bar/?\\Z'), + (b'foo/bar/*', b'(?ms)foo\\/bar\\/[^/]+/?\\Z'), + (b'/foo\\[bar\\]', b'(?ms)foo\\[bar\\]/?\\Z'), + (b'/foo[bar]', b'(?ms)foo[bar]/?\\Z'), + (b'/foo[0-9]', b'(?ms)foo[0-9]/?\\Z'), +] + + +@pytest.mark.usefixtures('fs') +class TestIgnoreFiles: + def test_translate(self) -> None: + for pattern, regex in TRANSLATE_TESTS: + if re.escape(b'/') == b'/': + regex = regex.replace(b'\\/', b'/') + assert ( + translate(pattern) == regex + ), f'orig pattern: {pattern!r}, regex: {translate(pattern)!r}, expected: {regex!r}' + + def test_read_file(self) -> None: + f = BytesIO( + b""" +# a comment +\x20\x20 +# and an empty line: + +\\#not a comment +!negative +with trailing whitespace +with escaped trailing whitespace\\ +""" # noqa: W291 (Trailing whitespace) + ) + assert list(read_ignore_patterns(f)) == [ + b'\\#not a comment', + b'!negative', + b'with trailing whitespace', + b'with escaped trailing whitespace ', + ] + + def test_match_patterns_positive(self) -> None: + for path, pattern in POSITIVE_MATCH_TESTS: + assert match_pattern(path, pattern), f'path: {path!r}, pattern: {pattern!r}' + + def test_match_patterns_negative(self) -> None: + for path, pattern in NEGATIVE_MATCH_TESTS: + assert not match_pattern(path, pattern), f'path: {path!r}, pattern: {pattern!r}' + + def test_ignore_filter_inclusion(self) -> None: + ignore_filter = IgnoreFilter([b'a.c', b'b.c']) + assert ignore_filter.is_ignored(b'a.c') + assert ignore_filter.is_ignored(b'c.c') is None + assert list(ignore_filter.find_matching(b'a.c')) == [Pattern(b'a.c')] + assert list(ignore_filter.find_matching(b'c.c')) == [] + + def test_ignore_filter_exclusion(self) -> None: + ignore_filter = IgnoreFilter([b'a.c', b'b.c', b'!c.c']) + assert not ignore_filter.is_ignored(b'c.c') + assert ignore_filter.is_ignored(b'd.c') is None + assert list(ignore_filter.find_matching(b'c.c')) == [Pattern(b'!c.c')] + assert list(ignore_filter.find_matching(b'd.c')) == [] + + def test_ignore_filter_manager(self, fs: 'FakeFilesystem') -> None: + # Prepare sample ignore patterns + fs.create_file('/path/to/repo/.gitignore', contents=b'/foo/bar\n/dir2\n/dir3/\n') + fs.create_file('/path/to/repo/dir/.gitignore', contents=b'/blie\n') + fs.create_file('/path/to/repo/.git/info/exclude', contents=b'/excluded\n') + + m = IgnoreFilterManager.build('/path/to/repo') + + assert m.is_ignored('dir/blie') + assert m.is_ignored(os.path.join('dir', 'bloe')) is None + assert m.is_ignored('dir') is None + assert m.is_ignored(os.path.join('foo', 'bar')) + assert m.is_ignored(os.path.join('excluded')) + assert m.is_ignored(os.path.join('dir2', 'fileinignoreddir')) + assert not m.is_ignored('dir3') + assert m.is_ignored('dir3/') + assert m.is_ignored('dir3/bla') + + def test_nested_gitignores(self, fs: 'FakeFilesystem') -> None: + fs.create_file('/path/to/repo/.gitignore', contents=b'/*\n!/foo\n') + fs.create_file('/path/to/repo/foo/.gitignore', contents=b'/bar\n') + fs.create_file('/path/to/repo/foo/bar', contents=b'IGNORED') + + m = IgnoreFilterManager.build('/path/to/repo') + assert m.is_ignored('foo/bar') + + def test_load_ignore_ignore_case(self, fs: 'FakeFilesystem') -> None: + fs.create_file('/path/to/repo/.gitignore', contents=b'/foo/bar\n/dir\n') + + m = IgnoreFilterManager.build('/path/to/repo', ignore_case=True) + assert m.is_ignored(os.path.join('dir', 'blie')) + assert m.is_ignored(os.path.join('DIR', 'blie')) + + def test_ignored_contents(self, fs: 'FakeFilesystem') -> None: + fs.create_file('/path/to/repo/.gitignore', contents=b'a/*\n!a/*.txt\n') + + m = IgnoreFilterManager.build('/path/to/repo') + assert m.is_ignored('a') is None + assert m.is_ignored('a/') is None + assert not m.is_ignored('a/b.txt') + assert m.is_ignored('a/c.dat') From 34b86d5eb43a51b1e430f6db3b60615a804a8ac3 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 17 Dec 2024 12:55:07 +0100 Subject: [PATCH 136/257] CM-42904 - Add `--by-cve` option for `cycode ignore` command (#274) Co-authored-by: elsapet --- cycode/cli/commands/ignore/ignore_command.py | 76 +++++++++++++------ cycode/cli/commands/scan/code_scanner.py | 64 +++++++++------- cycode/cli/consts.py | 1 + .../user_settings/configuration_manager.py | 3 +- 4 files changed, 93 insertions(+), 51 deletions(-) diff --git a/cycode/cli/commands/ignore/ignore_command.py b/cycode/cli/commands/ignore/ignore_command.py index ea73a8e6..b94c5612 100644 --- a/cycode/cli/commands/ignore/ignore_command.py +++ b/cycode/cli/commands/ignore/ignore_command.py @@ -1,20 +1,16 @@ -import os import re +from typing import Optional import click 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.path_utils import get_absolute_path, is_path_exists from cycode.cli.utils.string_utils import hash_string_to_sha256 from cycode.cyclient import logger -def _is_path_to_ignore_exists(path: str) -> bool: - return os.path.exists(path) - - def _is_package_pattern_valid(package: str) -> bool: return re.search('^[^@]+@[^@]+$', package) is not None @@ -47,10 +43,16 @@ def _is_package_pattern_valid(package: str) -> bool: required=False, help='Ignore scanning a specific package version while running an SCA scan. Expected pattern: name@version.', ) +@click.option( + '--by-cve', + type=click.STRING, + required=False, + help='Ignore scanning a specific CVE while running an SCA scan. Expected pattern: CVE-YYYY-NNN.', +) @click.option( '--scan-type', '-t', - default='secret', + default=consts.SECRET_SCAN_TYPE, help='Specify the type of scan you wish to execute (the default is Secrets).', type=click.Choice(config['scans']['supported_scans']), required=False, @@ -64,40 +66,68 @@ def _is_package_pattern_valid(package: str) -> bool: required=False, help='Add an ignore rule to the global CLI config.', ) -def ignore_command( - by_value: str, by_sha: str, by_path: str, by_rule: str, by_package: str, scan_type: str, is_global: bool +def ignore_command( # noqa: C901 + by_value: Optional[str], + by_sha: Optional[str], + by_path: Optional[str], + by_rule: Optional[str], + by_package: Optional[str], + by_cve: Optional[str], + scan_type: str = consts.SECRET_SCAN_TYPE, + is_global: bool = False, ) -> 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') + all_by_values = [by_value, by_sha, by_path, by_rule, by_package, by_cve] + if all(by is None for by in all_by_values): + raise click.ClickException('Ignore by type is missing') + if len([by for by in all_by_values if by is not None]) != 1: + raise click.ClickException('You must specify only one ignore by type') if any(by is not None for by in [by_value, by_sha]) and scan_type != consts.SECRET_SCAN_TYPE: - raise click.ClickException('this exclude is supported only for secret scan type') + raise click.ClickException('This exclude is supported only for Secret scan type') + if (by_cve or by_package) and scan_type != consts.SCA_SCAN_TYPE: + raise click.ClickException('This exclude is supported only for SCA scan type') + + # only one of the by values must be set + # at least one of the by values must be set + exclusion_type = exclusion_value = None - if by_value is not None: + if by_value: exclusion_type = consts.EXCLUSIONS_BY_VALUE_SECTION_NAME exclusion_value = hash_string_to_sha256(by_value) - elif by_sha is not None: + + if by_sha: exclusion_type = consts.EXCLUSIONS_BY_SHA_SECTION_NAME exclusion_value = by_sha - elif by_path is not None: + + if by_path: absolute_path = get_absolute_path(by_path) - if not _is_path_to_ignore_exists(absolute_path): - raise click.ClickException('the provided path to ignore by is not exist') + if not is_path_exists(absolute_path): + raise click.ClickException('The provided path to ignore by does not exist') + exclusion_type = consts.EXCLUSIONS_BY_PATH_SECTION_NAME exclusion_value = get_absolute_path(absolute_path) - elif by_package is not None: - if scan_type != consts.SCA_SCAN_TYPE: - raise click.ClickException('exclude by package is supported only for sca scan type') + + if by_rule: + exclusion_type = consts.EXCLUSIONS_BY_RULE_SECTION_NAME + exclusion_value = by_rule + + if by_package: if not _is_package_pattern_valid(by_package): raise click.ClickException('wrong package pattern. should be name@version.') + exclusion_type = consts.EXCLUSIONS_BY_PACKAGE_SECTION_NAME exclusion_value = by_package - else: - exclusion_type = consts.EXCLUSIONS_BY_RULE_SECTION_NAME - exclusion_value = by_rule + + if by_cve: + exclusion_type = consts.EXCLUSIONS_BY_CVE_SECTION_NAME + exclusion_value = by_cve + + if not exclusion_type or not exclusion_value: + # should never happen + raise click.ClickException('Invalid ignore by type') configuration_scope = 'global' if is_global else 'local' logger.debug( diff --git a/cycode/cli/commands/scan/code_scanner.py b/cycode/cli/commands/scan/code_scanner.py index 59e99900..42b305be 100644 --- a/cycode/cli/commands/scan/code_scanner.py +++ b/cycode/cli/commands/scan/code_scanner.py @@ -764,58 +764,68 @@ def _exclude_detections_by_exclusions_configuration(detections: List[Detection], def _should_exclude_detection(detection: Detection, exclusions: Dict) -> bool: + # FIXME(MarshalX): what the difference between by_value and by_sha? exclusions_by_value = exclusions.get(consts.EXCLUSIONS_BY_VALUE_SECTION_NAME, []) if _is_detection_sha_configured_in_exclusions(detection, exclusions_by_value): logger.debug( - 'Going to ignore violations because they are on the values-to-ignore list, %s', - {'value_sha': detection.detection_details.get('sha512', '')}, + 'Ignoring violation because its value is on the ignore list, %s', + {'value_sha': detection.detection_details.get('sha512')}, ) return True exclusions_by_sha = exclusions.get(consts.EXCLUSIONS_BY_SHA_SECTION_NAME, []) if _is_detection_sha_configured_in_exclusions(detection, exclusions_by_sha): logger.debug( - 'Going to ignore violations because they are on the SHA ignore list, %s', - {'sha': detection.detection_details.get('sha512', '')}, + 'Ignoring violation because its SHA value is on the ignore list, %s', + {'sha': detection.detection_details.get('sha512')}, ) return True exclusions_by_rule = exclusions.get(consts.EXCLUSIONS_BY_RULE_SECTION_NAME, []) - if exclusions_by_rule: - detection_rule = detection.detection_rule_id - if detection_rule in exclusions_by_rule: - logger.debug( - 'Going to ignore violations because they are on the Rule ID ignore list, %s', - {'detection_rule': detection_rule}, - ) - return True + detection_rule_id = detection.detection_rule_id + if detection_rule_id in exclusions_by_rule: + logger.debug( + 'Ignoring violation because its Detection Rule ID is on the ignore list, %s', + {'detection_rule_id': detection_rule_id}, + ) + return True exclusions_by_package = exclusions.get(consts.EXCLUSIONS_BY_PACKAGE_SECTION_NAME, []) - if exclusions_by_package: - package = _get_package_name(detection) - if package in exclusions_by_package: - logger.debug( - 'Going to ignore violations because they are on the packages-to-ignore list, %s', {'package': package} - ) - return True + package = _get_package_name(detection) + if package and package in exclusions_by_package: + logger.debug('Ignoring violation because its package@version is on the ignore list, %s', {'package': package}) + return True + + exclusions_by_cve = exclusions.get(consts.EXCLUSIONS_BY_CVE_SECTION_NAME, []) + cve = _get_cve_identifier(detection) + if cve and cve in exclusions_by_cve: + logger.debug('Ignoring violation because its CVE is on the ignore list, %s', {'cve': cve}) + return True return False def _is_detection_sha_configured_in_exclusions(detection: Detection, exclusions: List[str]) -> bool: - detection_sha = detection.detection_details.get('sha512', '') + detection_sha = detection.detection_details.get('sha512') return detection_sha in exclusions -def _get_package_name(detection: Detection) -> str: - package_name = detection.detection_details.get('vulnerable_component', '') - package_version = detection.detection_details.get('vulnerable_component_version', '') +def _get_package_name(detection: Detection) -> Optional[str]: + package_name = detection.detection_details.get('vulnerable_component') + package_version = detection.detection_details.get('vulnerable_component_version') + + if package_name is None: + package_name = detection.detection_details.get('package_name') + package_version = detection.detection_details.get('package_version') + + if package_name and package_version: + return f'{package_name}@{package_version}' + + return None - if package_name == '': - package_name = detection.detection_details.get('package_name', '') - package_version = detection.detection_details.get('package_version', '') - return f'{package_name}@{package_version}' +def _get_cve_identifier(detection: Detection) -> Optional[str]: + return detection.detection_details.get('alert', {}).get('cve_identifier') def _get_document_by_file_name( diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index cd546075..b4b09a15 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -131,6 +131,7 @@ EXCLUSIONS_BY_PATH_SECTION_NAME = 'paths' EXCLUSIONS_BY_RULE_SECTION_NAME = 'rules' EXCLUSIONS_BY_PACKAGE_SECTION_NAME = 'packages' +EXCLUSIONS_BY_CVE_SECTION_NAME = 'cves' # 5MB in bytes (in decimal) FILE_MAX_SIZE_LIMIT_IN_BYTES = 5000000 diff --git a/cycode/cli/user_settings/configuration_manager.py b/cycode/cli/user_settings/configuration_manager.py index b83bed32..f8d67c42 100644 --- a/cycode/cli/user_settings/configuration_manager.py +++ b/cycode/cli/user_settings/configuration_manager.py @@ -79,7 +79,8 @@ def add_exclusion(self, scope: str, scan_type: str, exclusion_type: str, value: config_file_manager = self.get_config_file_manager(scope) config_file_manager.add_exclusion(scan_type, exclusion_type, value) - def _merge_exclusions(self, local_exclusions: Dict, global_exclusions: Dict) -> Dict: + @staticmethod + def _merge_exclusions(local_exclusions: Dict, global_exclusions: Dict) -> Dict: keys = set(list(local_exclusions.keys()) + list(global_exclusions.keys())) return {key: local_exclusions.get(key, []) + global_exclusions.get(key, []) for key in keys} From 1c4d549c1a88c27be83fd1edc14c2ea9c8fd1f02 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 18 Dec 2024 16:33:44 +0100 Subject: [PATCH 137/257] CM-42771 - Fix top level `.gitignore` priorities (#275) --- cycode/cli/files_collector/walk_ignore.py | 3 ++- cycode/cli/utils/ignore_utils.py | 6 ++---- tests/cli/files_collector/test_walk_ignore.py | 17 +++++++++++++++++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/cycode/cli/files_collector/walk_ignore.py b/cycode/cli/files_collector/walk_ignore.py index 76d04366..93286c87 100644 --- a/cycode/cli/files_collector/walk_ignore.py +++ b/cycode/cli/files_collector/walk_ignore.py @@ -24,7 +24,8 @@ def _walk_to_top(path: str) -> Iterable[str]: def _collect_top_level_ignore_files(path: str) -> List[str]: ignore_files = [] - for dir_path in _walk_to_top(path): + top_paths = reversed(list(_walk_to_top(path))) # we must reverse it to make top levels more prioritized + for dir_path in top_paths: for ignore_file in _SUPPORTED_IGNORE_PATTERN_FILES: ignore_file_path = os.path.join(dir_path, ignore_file) if os.path.exists(ignore_file_path): diff --git a/cycode/cli/utils/ignore_utils.py b/cycode/cli/utils/ignore_utils.py index 329fa055..f44b6024 100644 --- a/cycode/cli/utils/ignore_utils.py +++ b/cycode/cli/utils/ignore_utils.py @@ -396,12 +396,10 @@ def walk(self, **kwargs) -> Generator[Tuple[str, List[str], List[str]], None, No # decrease recursion depth of os.walk() by ignoring subdirectories because of topdown=True # slicing ([:]) is mandatory to change dict in-place! - dirnames[:] = [ - dirname for dirname in dirnames if not self.is_ignored(os.path.join(rel_dirpath, dirname, '')) - ] + dirnames[:] = [d for d in dirnames if not self.is_ignored(os.path.join(rel_dirpath, d))] # remove ignored files - filenames = [os.path.basename(f) for f in filenames if not self.is_ignored(os.path.join(rel_dirpath, f))] + filenames = [f for f in filenames if not self.is_ignored(os.path.join(rel_dirpath, f))] yield dirpath, dirnames, filenames diff --git a/tests/cli/files_collector/test_walk_ignore.py b/tests/cli/files_collector/test_walk_ignore.py index fd2612d5..b771cdf9 100644 --- a/tests/cli/files_collector/test_walk_ignore.py +++ b/tests/cli/files_collector/test_walk_ignore.py @@ -140,3 +140,20 @@ def test_walk_ignore(fs: 'FakeFilesystem') -> None: assert normpath('/home/user/project/subproject/ignored.pyc') not in result # presented: assert normpath('/home/user/project/subproject/presented.py') in result + + +def test_walk_ignore_top_level_ignores_order(fs: 'FakeFilesystem') -> None: + fs.create_file('/home/user/.gitignore', contents='*.log') + fs.create_file('/home/user/project/.gitignore', contents='!*.log') # rollback *.log ignore for project + fs.create_dir('/home/user/project/subproject') + + fs.create_file('/home/user/ignored.log') + fs.create_file('/home/user/project/presented.log') + fs.create_file('/home/user/project/subproject/presented.log') + + path = normpath('/home/user/project') + result = _collect_walk_ignore_files(path) + assert len(result) == 3 + assert normpath('/home/user/ignored.log') not in result + assert normpath('/home/user/project/presented.log') in result + assert normpath('/home/user/project/subproject/presented.log') in result From a4c7e860a7060c94e6da3817f29e9cdcaa91867d Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 28 Jan 2025 16:29:46 +0100 Subject: [PATCH 138/257] CM-27209 - Add latest CLI version check (#276) --- .github/workflows/tests_full.yml | 4 +- cycode/cli/commands/main_cli.py | 32 ++- .../cli/commands/version/version_checker.py | 209 ++++++++++++++++++ cycode/cli/utils/path_utils.py | 7 +- .../test_check_latest_version_on_close.py | 71 ++++++ tests/cli/commands/version/__init__.py | 0 .../commands/version/test_version_checker.py | 129 +++++++++++ 7 files changed, 448 insertions(+), 4 deletions(-) create mode 100644 cycode/cli/commands/version/version_checker.py create mode 100644 tests/cli/commands/test_check_latest_version_on_close.py create mode 100644 tests/cli/commands/version/__init__.py create mode 100644 tests/cli/commands/version/test_version_checker.py diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index a760d617..985a3d36 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -65,7 +65,9 @@ jobs: run: poetry install - name: Run executable test - if: matrix.python-version != '3.13' # we will migrate pyinstaller to 3.13 later + # we care about the one Python version that will be used to build the executable + # TODO(MarshalX): upgrade to Python 3.13 + if: matrix.python-version == '3.12' run: | poetry run pyinstaller pyinstaller.spec ./dist/cycode-cli version diff --git a/cycode/cli/commands/main_cli.py b/cycode/cli/commands/main_cli.py index f97e0749..59b8625f 100644 --- a/cycode/cli/commands/main_cli.py +++ b/cycode/cli/commands/main_cli.py @@ -3,6 +3,7 @@ import click +from cycode import __version__ from cycode.cli.commands.ai_remediation.ai_remediation_command import ai_remediation_command from cycode.cli.commands.auth.auth_command import auth_command from cycode.cli.commands.configure.configure_command import configure_command @@ -10,6 +11,7 @@ from cycode.cli.commands.report.report_command import report_command from cycode.cli.commands.scan.scan_command import scan_command from cycode.cli.commands.status.status_command import status_command +from cycode.cli.commands.version.version_checker import version_checker from cycode.cli.commands.version.version_command import version_command from cycode.cli.consts import ( CLI_CONTEXT_SETTINGS, @@ -48,6 +50,12 @@ default=False, help='Do not show the progress meter.', ) +@click.option( + '--no-update-notifier', + is_flag=True, + default=False, + help='Do not check CLI for updates.', +) @click.option( '--output', '-o', @@ -63,7 +71,12 @@ ) @click.pass_context def main_cli( - context: click.Context, verbose: bool, no_progress_meter: bool, output: str, user_agent: Optional[str] + context: click.Context, + verbose: bool, + no_progress_meter: bool, + no_update_notifier: bool, + output: str, + user_agent: Optional[str], ) -> None: init_sentry() add_breadcrumb('cycode') @@ -85,3 +98,20 @@ def main_cli( if user_agent: user_agent_option = UserAgentOptionScheme().loads(user_agent) CycodeClientBase.enrich_user_agent(user_agent_option.user_agent_suffix) + + if not no_update_notifier: + context.call_on_close(lambda: check_latest_version_on_close()) + + +@click.pass_context +def check_latest_version_on_close(context: click.Context) -> None: + output = context.obj.get('output') + # don't print anything if the output is JSON + if output == 'json': + return + + # we always want to check the latest version for "version" and "status" commands + should_use_cache = context.invoked_subcommand not in {'version', 'status'} + version_checker.check_and_notify_update( + current_version=__version__, use_color=context.color, use_cache=should_use_cache + ) diff --git a/cycode/cli/commands/version/version_checker.py b/cycode/cli/commands/version/version_checker.py new file mode 100644 index 00000000..c5ec9d4f --- /dev/null +++ b/cycode/cli/commands/version/version_checker.py @@ -0,0 +1,209 @@ +import os +import re +import time +from pathlib import Path +from typing import List, Optional, Tuple + +import click + +from cycode.cli.user_settings.configuration_manager import ConfigurationManager +from cycode.cli.utils.path_utils import get_file_content +from cycode.cyclient.cycode_client_base import CycodeClientBase + + +def _compare_versions( + current_parts: List[int], + latest_parts: List[int], + current_is_pre: bool, + latest_is_pre: bool, + latest_version: str, +) -> Optional[str]: + """Compare version numbers and determine if an update is needed. + + Implements version comparison logic with special handling for pre-release versions: + - Won't suggest downgrading from stable to pre-release + - Will suggest upgrading from pre-release to stable of the same version + + Args: + current_parts: List of numeric version components for the current version + latest_parts: List of numeric version components for the latest version + current_is_pre: Whether the current version is pre-release + latest_is_pre: Whether the latest version is pre-release + latest_version: The full latest version string + + Returns: + str | None: The latest version string if an update is recommended, + None if no update is needed + """ + # If current is stable and latest is pre-release, don't suggest update + if not current_is_pre and latest_is_pre: + return None + + # Compare version numbers + for current, latest in zip(current_parts, latest_parts): + if latest > current: + return latest_version + if current > latest: + return None + + # If all numbers are equal, suggest update if current is pre-release and latest is stable + if current_is_pre and not latest_is_pre: + return latest_version + + return None + + +class VersionChecker(CycodeClientBase): + PYPI_API_URL = 'https://pypi.org/pypi' + PYPI_PACKAGE_NAME = 'cycode' + + GIT_CHANGELOG_URL_PREFIX = 'https://github.com/cycodehq/cycode-cli/releases/tag/v' + + DAILY = 24 * 60 * 60 # 24 hours in seconds + WEEKLY = DAILY * 7 + + def __init__(self) -> None: + """Initialize the VersionChecker. + + Sets up the version checker with PyPI API URL and configure the cache file location + using the global configuration directory. + """ + super().__init__(self.PYPI_API_URL) + + configuration_manager = ConfigurationManager() + config_dir = configuration_manager.global_config_file_manager.get_config_directory_path() + self.cache_file = Path(config_dir) / '.version_check' + + def get_latest_version(self) -> Optional[str]: + """Fetch the latest version of the package from PyPI. + + Makes an HTTP request to PyPI's JSON API to get the latest version information. + + Returns: + str | None: The latest version string if successful, None if the request fails + or the version information is not available. + """ + try: + response = self.get(f'{self.PYPI_PACKAGE_NAME}/json') + data = response.json() + return data.get('info', {}).get('version') + except Exception: + return None + + @staticmethod + def _parse_version(version: str) -> Tuple[List[int], bool]: + """Parse version string into components and identify if it's a pre-release. + + Extracts numeric version components and determines if the version is a pre-release + by checking for 'dev' in the version string. + + Args: + version: The version string to parse (e.g., '1.2.3' or '1.2.3dev4') + + Returns: + tuple: A tuple containing: + - List[int]: List of numeric version components + - bool: True if this is a pre-release version, False otherwise + """ + version_parts = [int(x) for x in re.findall(r'\d+', version)] + is_prerelease = 'dev' in version + + return version_parts, is_prerelease + + def _should_check_update(self, is_prerelease: bool) -> bool: + """Determine if an update check should be performed based on the last check time. + + Implements a time-based caching mechanism where update checks are performed: + - Daily for pre-release versions + - Weekly for stable versions + + Args: + is_prerelease: Whether the current version is a pre-release + + Returns: + bool: True if an update check should be performed, False otherwise + """ + if not os.path.exists(self.cache_file): + return True + + file_content = get_file_content(self.cache_file) + if file_content is None: + return True + + try: + last_check = float(file_content.strip()) + except ValueError: + return True + + duration = self.DAILY if is_prerelease else self.WEEKLY + return time.time() - last_check >= duration + + def _update_last_check(self) -> None: + """Update the timestamp of the last update check. + + Creates the cache directory if it doesn't exist and write the current timestamp + to the cache file. Silently handle any IO errors that might occur during the process. + """ + try: + os.makedirs(os.path.dirname(self.cache_file), exist_ok=True) + with open(self.cache_file, 'w', encoding='UTF-8') as f: + f.write(str(time.time())) + except IOError: + pass + + def check_for_update(self, current_version: str, use_cache: bool = True) -> Optional[str]: + """Check if an update is available for the current version. + + Respects the update check frequency (daily/weekly) based on the version type + + Args: + current_version: The current version string of the CLI + use_cache: If True, use the cached timestamp to determine if an update check is needed + + Returns: + str | None: The latest version string if an update is recommended, + None if no update is needed or if check should be skipped + """ + current_parts, current_is_pre = self._parse_version(current_version) + + # Check if we should perform the update check based on frequency + if use_cache and not self._should_check_update(current_is_pre): + return None + + latest_version = self.get_latest_version() + if not latest_version: + return None + + # Update the last check timestamp + use_cache and self._update_last_check() + + latest_parts, latest_is_pre = self._parse_version(latest_version) + return _compare_versions(current_parts, latest_parts, current_is_pre, latest_is_pre, latest_version) + + def check_and_notify_update(self, current_version: str, use_color: bool = True, use_cache: bool = True) -> None: + """Check for updates and display a notification if a new version is available. + + Performs the version check and displays a formatted message with update instructions + if a newer version is available. The message includes: + - Current and new version numbers + - Link to the changelog + - Command to perform the update + + Args: + current_version: Current version of the CLI + use_color: If True, use colored output in the terminal + use_cache: If True, use the cached timestamp to determine if an update check is needed + """ + latest_version = self.check_for_update(current_version, use_cache) + should_update = bool(latest_version) + if should_update: + update_message = ( + '\nNew version of cycode available! ' + f"{click.style(current_version, fg='yellow')} → {click.style(latest_version, fg='bright_blue')}\n" + f"Changelog: {click.style(f'{self.GIT_CHANGELOG_URL_PREFIX}{latest_version}', fg='bright_blue')}\n" + f"Run {click.style('pip install --upgrade cycode', fg='green')} to update\n" + ) + click.echo(update_message, color=use_color) + + +version_checker = VersionChecker() diff --git a/cycode/cli/utils/path_utils.py b/cycode/cli/utils/path_utils.py index 4f8be3f1..a2d8816b 100644 --- a/cycode/cli/utils/path_utils.py +++ b/cycode/cli/utils/path_utils.py @@ -1,13 +1,16 @@ import json import os from functools import lru_cache -from typing import AnyStr, List, Optional +from typing import TYPE_CHECKING, AnyStr, List, Optional, Union import click from binaryornot.helpers import is_binary_string from cycode.cyclient import logger +if TYPE_CHECKING: + from os import PathLike + @lru_cache(maxsize=None) def is_sub_path(path: str, sub_path: str) -> bool: @@ -73,7 +76,7 @@ def join_paths(path: str, filename: str) -> str: return os.path.join(path, filename) -def get_file_content(file_path: str) -> Optional[AnyStr]: +def get_file_content(file_path: Union[str, 'PathLike']) -> Optional[AnyStr]: try: with open(file_path, 'r', encoding='UTF-8') as f: return f.read() diff --git a/tests/cli/commands/test_check_latest_version_on_close.py b/tests/cli/commands/test_check_latest_version_on_close.py new file mode 100644 index 00000000..189973b4 --- /dev/null +++ b/tests/cli/commands/test_check_latest_version_on_close.py @@ -0,0 +1,71 @@ +from unittest.mock import patch + +import pytest +from click.testing import CliRunner + +from cycode import __version__ +from cycode.cli.commands.main_cli import main_cli +from cycode.cli.commands.version.version_checker import VersionChecker +from tests.conftest import CLI_ENV_VARS + +_NEW_LATEST_VERSION = '999.0.0' # Simulate a newer version available +_UPDATE_MESSAGE_PART = 'new version of cycode available' + + +@patch.object(VersionChecker, 'check_for_update') +def test_version_check_with_json_output(mock_check_update: patch) -> None: + # When output is JSON, version check should be skipped + mock_check_update.return_value = _NEW_LATEST_VERSION + + args = ['--output', 'json', 'version'] + result = CliRunner().invoke(main_cli, args, env=CLI_ENV_VARS) + + # Version check message should not be present in JSON output + assert _UPDATE_MESSAGE_PART not in result.output.lower() + mock_check_update.assert_not_called() + + +@pytest.fixture +def mock_auth_info() -> 'patch': + # Mock the authorization info to avoid API calls + with patch('cycode.cli.commands.status.status_command.get_authorization_info', return_value=None) as mock: + yield mock + + +@pytest.mark.parametrize('command', ['version', 'status']) +@patch.object(VersionChecker, 'check_for_update') +def test_version_check_for_special_commands(mock_check_update: patch, mock_auth_info: patch, command: str) -> None: + # Version and status commands should always check the version without cache + mock_check_update.return_value = _NEW_LATEST_VERSION + + result = CliRunner().invoke(main_cli, [command], env=CLI_ENV_VARS) + + # Version information should be present in output + assert _UPDATE_MESSAGE_PART in result.output.lower() + # Version check must be called without a cache + mock_check_update.assert_called_once_with(__version__, False) + + +@patch.object(VersionChecker, 'check_for_update') +def test_version_check_with_text_output(mock_check_update: patch) -> None: + # Regular commands with text output should check the version using cache + mock_check_update.return_value = _NEW_LATEST_VERSION + + args = ['version'] + result = CliRunner().invoke(main_cli, args, env=CLI_ENV_VARS) + + # Version check message should be present in JSON output + assert _UPDATE_MESSAGE_PART in result.output.lower() + + +@patch.object(VersionChecker, 'check_for_update') +def test_version_check_disabled(mock_check_update: patch) -> None: + # When --no-update-notifier is used, version check should be skipped + mock_check_update.return_value = _NEW_LATEST_VERSION + + args = ['--no-update-notifier', 'version'] + result = CliRunner().invoke(main_cli, args, env=CLI_ENV_VARS) + + # Version check message should not be present + assert _UPDATE_MESSAGE_PART not in result.output.lower() + mock_check_update.assert_not_called() diff --git a/tests/cli/commands/version/__init__.py b/tests/cli/commands/version/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/commands/version/test_version_checker.py b/tests/cli/commands/version/test_version_checker.py new file mode 100644 index 00000000..eb0b9bd9 --- /dev/null +++ b/tests/cli/commands/version/test_version_checker.py @@ -0,0 +1,129 @@ +import time +from typing import TYPE_CHECKING, Optional +from unittest.mock import MagicMock, patch + +import pytest + +if TYPE_CHECKING: + from pathlib import Path + +from cycode.cli.commands.version.version_checker import VersionChecker + + +@pytest.fixture +def version_checker() -> 'VersionChecker': + return VersionChecker() + + +@pytest.fixture +def version_checker_cached(tmp_path: 'Path', version_checker: 'VersionChecker') -> 'VersionChecker': + version_checker.cache_file = tmp_path / '.version_check' + return version_checker + + +class TestVersionChecker: + def test_parse_version_stable(self, version_checker: 'VersionChecker') -> None: + version = '1.2.3' + parts, is_pre = version_checker._parse_version(version) + + assert parts == [1, 2, 3] + assert not is_pre + + def test_parse_version_prerelease(self, version_checker: 'VersionChecker') -> None: + version = '1.2.3dev4' + parts, is_pre = version_checker._parse_version(version) + + assert parts == [1, 2, 3, 4] + assert is_pre + + def test_parse_version_complex(self, version_checker: 'VersionChecker') -> None: + version = '1.2.3.dev4.post5' + parts, is_pre = version_checker._parse_version(version) + + assert parts == [1, 2, 3, 4, 5] + assert is_pre + + def test_should_check_update_no_cache(self, version_checker_cached: 'VersionChecker') -> None: + assert version_checker_cached._should_check_update(is_prerelease=False) is True + + def test_should_check_update_invalid_cache(self, version_checker_cached: 'VersionChecker') -> None: + version_checker_cached.cache_file.write_text('invalid') + assert version_checker_cached._should_check_update(is_prerelease=False) is True + + def test_should_check_update_expired(self, version_checker_cached: 'VersionChecker') -> None: + # Write a timestamp from 8 days ago + old_time = time.time() - (8 * 24 * 60 * 60) + version_checker_cached.cache_file.write_text(str(old_time)) + + assert version_checker_cached._should_check_update(is_prerelease=False) is True + + def test_should_check_update_not_expired(self, version_checker_cached: 'VersionChecker') -> None: + # Write a recent timestamp + version_checker_cached.cache_file.write_text(str(time.time())) + + assert version_checker_cached._should_check_update(is_prerelease=False) is False + + def test_should_check_update_prerelease_daily(self, version_checker_cached: 'VersionChecker') -> None: + # Write a timestamp from 25 hours ago + old_time = time.time() - (25 * 60 * 60) + version_checker_cached.cache_file.write_text(str(old_time)) + + assert version_checker_cached._should_check_update(is_prerelease=True) is True + + @pytest.mark.parametrize( + 'current_version, latest_version, expected_result', + [ + # Stable version comparisons + ('1.2.3', '1.2.4', '1.2.4'), # Higher patch version + ('1.2.3', '1.3.0', '1.3.0'), # Higher minor version + ('1.2.3', '2.0.0', '2.0.0'), # Higher major version + ('1.2.3', '1.2.3', None), # Same version + ('1.2.4', '1.2.3', None), # Current higher than latest + # Pre-release version comparisons + ('1.2.3dev1', '1.2.3', '1.2.3'), # Pre-release to stable + ('1.2.3', '1.2.4dev1', None), # Stable to pre-release + ('1.2.3dev1', '1.2.3dev2', '1.2.3dev2'), # Pre-release to higher pre-release + ('1.2.3dev2', '1.2.3dev1', None), # Pre-release to lower pre-release + # Edge cases + ('1.0.0dev1', '1.0.0', '1.0.0'), # Pre-release to same version stable + ('2.0.0', '2.0.0dev1', None), # Stable to same version pre-release + ('2.2.1.dev4', '2.2.0', None), # Pre-release to lower stable + ], + ) + def test_check_for_update_scenarios( + self, + version_checker_cached: 'VersionChecker', + current_version: str, + latest_version: str, + expected_result: Optional[str], + ) -> None: + with patch.multiple( + version_checker_cached, + _should_check_update=MagicMock(return_value=True), + get_latest_version=MagicMock(return_value=latest_version), + _update_last_check=MagicMock(), + ): + result = version_checker_cached.check_for_update(current_version) + assert result == expected_result + + def test_get_latest_version_success(self, version_checker: 'VersionChecker') -> None: + mock_response = MagicMock() + mock_response.json.return_value = {'info': {'version': '1.2.3'}} + with patch.object(version_checker, 'get', return_value=mock_response): + assert version_checker.get_latest_version() == '1.2.3' + + def test_get_latest_version_failure(self, version_checker: 'VersionChecker') -> None: + with patch.object(version_checker, 'get', side_effect=Exception): + assert version_checker.get_latest_version() is None + + def test_update_last_check(self, version_checker_cached: 'VersionChecker') -> None: + version_checker_cached._update_last_check() + assert version_checker_cached.cache_file.exists() + + timestamp = float(version_checker_cached.cache_file.read_text().strip()) + assert abs(timestamp - time.time()) < 1 # Should be within 1 second + + def test_update_last_check_permission_error(self, version_checker_cached: 'VersionChecker') -> None: + with patch('builtins.open', side_effect=IOError): + version_checker_cached._update_last_check() + # Should not raise an exception From ac54f890b512d6f09ad83ebd3e2a5f066706b1d5 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 18 Feb 2025 10:41:02 +0100 Subject: [PATCH 139/257] CM-44880 - Update severity threshold docs (#277) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9f48da38..98d22d93 100644 --- a/README.md +++ b/README.md @@ -417,11 +417,11 @@ To limit the results of the `sca` scan to a specific severity threshold, add the Consider the following example. The following command will scan the repository for SCA policy violations that have a severity of Medium or higher: -`cycode scan -t sca --security-threshold MEDIUM repository ~/home/git/codebase` +`cycode scan -t sca --severity-threshold MEDIUM repository ~/home/git/codebase` or: -`cycode scan --scan-type sca --security-threshold MEDIUM repository ~/home/git/codebase` +`cycode scan --scan-type sca --severity-threshold MEDIUM repository ~/home/git/codebase` ### Path Scan From 70a62abd8d960e58d2608fa003f9ba28b526636c Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Mon, 24 Feb 2025 12:18:06 +0100 Subject: [PATCH 140/257] CM-44524 - Increase project chunk size for SAST (#279) --- cycode/cli/commands/scan/code_scanner.py | 5 +++-- cycode/cli/commands/scan/scan_command.py | 3 ++- cycode/cli/consts.py | 14 +++++++----- cycode/cli/files_collector/zip_documents.py | 9 +++----- cycode/cli/utils/scan_batch.py | 22 ++++++++----------- cycode/cyclient/scan_client.py | 6 ++--- cycode/cyclient/scan_config_base.py | 12 +++++----- tests/cli/commands/test_main_command.py | 9 +++++--- .../scan_config/test_default_scan_config.py | 11 +++++----- .../scan_config/test_dev_scan_config.py | 11 +++++----- tests/cyclient/test_scan_client.py | 9 ++++---- tests/test_code_scanner.py | 5 +++-- 12 files changed, 60 insertions(+), 56 deletions(-) diff --git a/cycode/cli/commands/scan/code_scanner.py b/cycode/cli/commands/scan/code_scanner.py index 42b305be..b3fddf59 100644 --- a/cycode/cli/commands/scan/code_scanner.py +++ b/cycode/cli/commands/scan/code_scanner.py @@ -301,6 +301,7 @@ def scan_documents( if not scan_parameters: scan_parameters = get_default_scan_parameters(context) + scan_type = context.obj['scan_type'] progress_bar = context.obj['progress_bar'] if not documents_to_scan: @@ -318,13 +319,13 @@ def scan_documents( context, is_git_diff, is_commit_range, scan_parameters ) errors, local_scan_results = run_parallel_batched_scan( - scan_batch_thread_func, documents_to_scan, progress_bar=progress_bar + scan_batch_thread_func, scan_type, documents_to_scan, progress_bar=progress_bar ) if len(local_scan_results) > 1: # if we used more than one batch, we need to fetch aggregate report url aggregation_report_url = _try_get_aggregation_report_url_if_needed( - scan_parameters, context.obj['client'], context.obj['scan_type'] + scan_parameters, context.obj['client'], scan_type ) set_aggregation_report_url(context, aggregation_report_url) diff --git a/cycode/cli/commands/scan/scan_command.py b/cycode/cli/commands/scan/scan_command.py index 5282dfb7..37b0a227 100644 --- a/cycode/cli/commands/scan/scan_command.py +++ b/cycode/cli/commands/scan/scan_command.py @@ -3,6 +3,7 @@ import click +from cycode.cli import consts from cycode.cli.commands.scan.commit_history.commit_history_command import commit_history_command from cycode.cli.commands.scan.path.path_command import path_command from cycode.cli.commands.scan.pre_commit.pre_commit_command import pre_commit_command @@ -34,7 +35,7 @@ @click.option( '--scan-type', '-t', - default='secret', + default=consts.SECRET_SCAN_TYPE, help='Specify the type of scan you wish to execute (the default is Secrets).', type=click.Choice(config['scans']['supported_scans']), ) diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index b4b09a15..3640d82a 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -136,14 +136,16 @@ # 5MB in bytes (in decimal) FILE_MAX_SIZE_LIMIT_IN_BYTES = 5000000 -# 20MB in bytes (in binary) -ZIP_MAX_SIZE_LIMIT_IN_BYTES = 20971520 -# 200MB in bytes (in binary) -SCA_ZIP_MAX_SIZE_LIMIT_IN_BYTES = 209715200 +DEFAULT_ZIP_MAX_SIZE_LIMIT_IN_BYTES = 20 * 1024 * 1024 +ZIP_MAX_SIZE_LIMIT_IN_BYTES = { + SCA_SCAN_TYPE: 200 * 1024 * 1024, + SAST_SCAN_TYPE: 50 * 1024 * 1024, +} # scan in batches -SCAN_BATCH_MAX_SIZE_IN_BYTES = 9 * 1024 * 1024 -SCAN_BATCH_MAX_FILES_COUNT = 1000 +DEFAULT_SCAN_BATCH_MAX_SIZE_IN_BYTES = 9 * 1024 * 1024 +SCAN_BATCH_MAX_SIZE_IN_BYTES = {SAST_SCAN_TYPE: 50 * 1024 * 1024} +DEFAULT_SCAN_BATCH_MAX_FILES_COUNT = 1000 # if we increase this values, the server doesn't allow connecting (ConnectionError) SCAN_BATCH_MAX_PARALLEL_SCANS = 5 SCAN_BATCH_SCANS_PER_CPU = 1 diff --git a/cycode/cli/files_collector/zip_documents.py b/cycode/cli/files_collector/zip_documents.py index 7d57a47c..9547f7fb 100644 --- a/cycode/cli/files_collector/zip_documents.py +++ b/cycode/cli/files_collector/zip_documents.py @@ -10,12 +10,9 @@ def _validate_zip_file_size(scan_type: str, zip_file_size: int) -> None: - if scan_type == consts.SCA_SCAN_TYPE: - if zip_file_size > consts.SCA_ZIP_MAX_SIZE_LIMIT_IN_BYTES: - raise custom_exceptions.ZipTooLargeError(consts.SCA_ZIP_MAX_SIZE_LIMIT_IN_BYTES) - else: - if zip_file_size > consts.ZIP_MAX_SIZE_LIMIT_IN_BYTES: - raise custom_exceptions.ZipTooLargeError(consts.ZIP_MAX_SIZE_LIMIT_IN_BYTES) + max_size_limit = consts.ZIP_MAX_SIZE_LIMIT_IN_BYTES.get(scan_type, consts.DEFAULT_ZIP_MAX_SIZE_LIMIT_IN_BYTES) + if zip_file_size > max_size_limit: + raise custom_exceptions.ZipTooLargeError(max_size_limit) def zip_documents(scan_type: str, documents: List[Document], zip_file: Optional[InMemoryZip] = None) -> InMemoryZip: diff --git a/cycode/cli/utils/scan_batch.py b/cycode/cli/utils/scan_batch.py index ede229e2..1ecfcf49 100644 --- a/cycode/cli/utils/scan_batch.py +++ b/cycode/cli/utils/scan_batch.py @@ -2,12 +2,7 @@ from multiprocessing.pool import ThreadPool from typing import TYPE_CHECKING, Callable, Dict, List, Tuple -from cycode.cli.consts import ( - SCAN_BATCH_MAX_FILES_COUNT, - SCAN_BATCH_MAX_PARALLEL_SCANS, - SCAN_BATCH_MAX_SIZE_IN_BYTES, - SCAN_BATCH_SCANS_PER_CPU, -) +from cycode.cli import consts from cycode.cli.models import Document from cycode.cli.utils.progress_bar import ScanProgressBarSection @@ -18,8 +13,8 @@ def split_documents_into_batches( documents: List[Document], - max_size_mb: int = SCAN_BATCH_MAX_SIZE_IN_BYTES, - max_files_count: int = SCAN_BATCH_MAX_FILES_COUNT, + max_size: int = consts.DEFAULT_SCAN_BATCH_MAX_SIZE_IN_BYTES, + max_files_count: int = consts.DEFAULT_SCAN_BATCH_MAX_FILES_COUNT, ) -> List[List[Document]]: batches = [] @@ -28,7 +23,7 @@ def split_documents_into_batches( for document in documents: document_size = len(document.content.encode('UTF-8')) - if (current_size + document_size > max_size_mb) or (len(current_batch) >= max_files_count): + if (current_size + document_size > max_size) or (len(current_batch) >= max_files_count): batches.append(current_batch) current_batch = [document] @@ -45,17 +40,18 @@ def split_documents_into_batches( def _get_threads_count() -> int: cpu_count = os.cpu_count() or 1 - return min(cpu_count * SCAN_BATCH_SCANS_PER_CPU, SCAN_BATCH_MAX_PARALLEL_SCANS) + return min(cpu_count * consts.SCAN_BATCH_SCANS_PER_CPU, consts.SCAN_BATCH_MAX_PARALLEL_SCANS) def run_parallel_batched_scan( scan_function: Callable[[List[Document]], Tuple[str, 'CliError', 'LocalScanResult']], + scan_type: str, documents: List[Document], progress_bar: 'BaseProgressBar', - max_size_mb: int = SCAN_BATCH_MAX_SIZE_IN_BYTES, - max_files_count: int = SCAN_BATCH_MAX_FILES_COUNT, ) -> Tuple[Dict[str, 'CliError'], List['LocalScanResult']]: - batches = split_documents_into_batches(documents, max_size_mb, max_files_count) + max_size = consts.SCAN_BATCH_MAX_SIZE_IN_BYTES.get(scan_type, consts.DEFAULT_SCAN_BATCH_MAX_SIZE_IN_BYTES) + batches = split_documents_into_batches(documents, max_size) + progress_bar.set_section_length(ScanProgressBarSection.SCAN, len(batches)) # * 3 # TODO(MarshalX): we should multiply the count of batches in SCAN section because each batch has 3 steps: # 1. scan creation diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index b63f49e1..31abba17 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -328,11 +328,11 @@ def parse_zipped_file_scan_response(response: Response) -> models.ZippedFileScan @staticmethod def get_service_name(scan_type: str) -> Optional[str]: # TODO(MarshalX): get_service_name should be removed from ScanClient? Because it exists in ScanConfig - if scan_type == 'secret': + if scan_type == consts.SECRET_SCAN_TYPE: return 'secret' - if scan_type == 'iac': + if scan_type == consts.INFRA_CONFIGURATION_SCAN_TYPE: return 'iac' - if scan_type == 'sca' or scan_type == 'sast': + if scan_type == consts.SCA_SCAN_TYPE or scan_type == consts.SAST_SCAN_TYPE: return 'scans' return None diff --git a/cycode/cyclient/scan_config_base.py b/cycode/cyclient/scan_config_base.py index e0bdd7ef..1ff1da6c 100644 --- a/cycode/cyclient/scan_config_base.py +++ b/cycode/cyclient/scan_config_base.py @@ -9,9 +9,9 @@ def get_service_name(self, scan_type: str, should_use_scan_service: bool = False @staticmethod def get_async_scan_type(scan_type: str) -> str: - if scan_type == 'secret': + if scan_type == consts.SECRET_SCAN_TYPE: return 'Secrets' - if scan_type == 'iac': + if scan_type == consts.INFRA_CONFIGURATION_SCAN_TYPE: return 'InfraConfiguration' return scan_type.upper() @@ -31,9 +31,9 @@ class DevScanConfig(ScanConfigBase): def get_service_name(self, scan_type: str, should_use_scan_service: bool = False) -> str: if should_use_scan_service: return '5004' - if scan_type == 'secret': + if scan_type == consts.SECRET_SCAN_TYPE: return '5025' - if scan_type == 'iac': + if scan_type == consts.INFRA_CONFIGURATION_SCAN_TYPE: return '5026' # sca and sast @@ -47,9 +47,9 @@ class DefaultScanConfig(ScanConfigBase): def get_service_name(self, scan_type: str, should_use_scan_service: bool = False) -> str: if should_use_scan_service: return 'scans' - if scan_type == 'secret': + if scan_type == consts.SECRET_SCAN_TYPE: return 'secret' - if scan_type == 'iac': + if scan_type == consts.INFRA_CONFIGURATION_SCAN_TYPE: return 'iac' # sca and sast diff --git a/tests/cli/commands/test_main_command.py b/tests/cli/commands/test_main_command.py index 32a55972..7e588cf2 100644 --- a/tests/cli/commands/test_main_command.py +++ b/tests/cli/commands/test_main_command.py @@ -6,6 +6,7 @@ import responses from click.testing import CliRunner +from cycode.cli import consts from cycode.cli.commands.main_cli import main_cli from cycode.cli.utils.git_proxy import git_proxy from tests.conftest import CLI_ENV_VARS, TEST_FILES_PATH, ZIP_CONTENT_PATH @@ -29,7 +30,7 @@ def _is_json(plain: str) -> bool: @responses.activate @pytest.mark.parametrize('output', ['text', 'json']) def test_passing_output_option(output: str, scan_client: 'ScanClient', api_token_response: responses.Response) -> None: - scan_type = 'secret' + scan_type = consts.SECRET_SCAN_TYPE scan_id = uuid4() mock_scan_responses(responses, scan_type, scan_client, scan_id, ZIP_CONTENT_PATH) @@ -52,8 +53,10 @@ def test_passing_output_option(output: str, scan_client: 'ScanClient', api_token @responses.activate def test_optional_git_with_path_scan(scan_client: 'ScanClient', api_token_response: responses.Response) -> None: - mock_scan_responses(responses, 'secret', scan_client, uuid4(), ZIP_CONTENT_PATH) - responses.add(get_zipped_file_scan_response(get_zipped_file_scan_url('secret', scan_client), ZIP_CONTENT_PATH)) + mock_scan_responses(responses, consts.SECRET_SCAN_TYPE, scan_client, uuid4(), ZIP_CONTENT_PATH) + responses.add( + get_zipped_file_scan_response(get_zipped_file_scan_url(consts.SECRET_SCAN_TYPE, scan_client), ZIP_CONTENT_PATH) + ) responses.add(api_token_response) # fake env without Git executable diff --git a/tests/cyclient/scan_config/test_default_scan_config.py b/tests/cyclient/scan_config/test_default_scan_config.py index e659f71f..75b305b5 100644 --- a/tests/cyclient/scan_config/test_default_scan_config.py +++ b/tests/cyclient/scan_config/test_default_scan_config.py @@ -1,14 +1,15 @@ +from cycode.cli import consts from cycode.cyclient.scan_config_base import DefaultScanConfig def test_get_service_name() -> None: default_scan_config = DefaultScanConfig() - assert default_scan_config.get_service_name('secret') == 'secret' - assert default_scan_config.get_service_name('iac') == 'iac' - assert default_scan_config.get_service_name('sca') == 'scans' - assert default_scan_config.get_service_name('sast') == 'scans' - assert default_scan_config.get_service_name('secret', True) == 'scans' + assert default_scan_config.get_service_name(consts.SECRET_SCAN_TYPE) == 'secret' + assert default_scan_config.get_service_name(consts.INFRA_CONFIGURATION_SCAN_TYPE) == 'iac' + assert default_scan_config.get_service_name(consts.SCA_SCAN_TYPE) == 'scans' + assert default_scan_config.get_service_name(consts.SAST_SCAN_TYPE) == 'scans' + assert default_scan_config.get_service_name(consts.SECRET_SCAN_TYPE, True) == 'scans' def test_get_detections_prefix() -> None: diff --git a/tests/cyclient/scan_config/test_dev_scan_config.py b/tests/cyclient/scan_config/test_dev_scan_config.py index 7419b002..63c99169 100644 --- a/tests/cyclient/scan_config/test_dev_scan_config.py +++ b/tests/cyclient/scan_config/test_dev_scan_config.py @@ -1,14 +1,15 @@ +from cycode.cli import consts from cycode.cyclient.scan_config_base import DevScanConfig def test_get_service_name() -> None: dev_scan_config = DevScanConfig() - assert dev_scan_config.get_service_name('secret') == '5025' - assert dev_scan_config.get_service_name('iac') == '5026' - assert dev_scan_config.get_service_name('sca') == '5004' - assert dev_scan_config.get_service_name('sast') == '5004' - assert dev_scan_config.get_service_name('secret', should_use_scan_service=True) == '5004' + assert dev_scan_config.get_service_name(consts.SECRET_SCAN_TYPE) == '5025' + assert dev_scan_config.get_service_name(consts.INFRA_CONFIGURATION_SCAN_TYPE) == '5026' + assert dev_scan_config.get_service_name(consts.SCA_SCAN_TYPE) == '5004' + assert dev_scan_config.get_service_name(consts.SAST_SCAN_TYPE) == '5004' + assert dev_scan_config.get_service_name(consts.SECRET_SCAN_TYPE, should_use_scan_service=True) == '5004' def test_get_detections_prefix() -> None: diff --git a/tests/cyclient/test_scan_client.py b/tests/cyclient/test_scan_client.py index d51e43f6..2b8fc3f3 100644 --- a/tests/cyclient/test_scan_client.py +++ b/tests/cyclient/test_scan_client.py @@ -8,6 +8,7 @@ from requests import Timeout from requests.exceptions import ProxyError +from cycode.cli import consts from cycode.cli.config import config from cycode.cli.exceptions.custom_exceptions import ( CycodeError, @@ -49,10 +50,10 @@ def get_test_zip_file(scan_type: str) -> InMemoryZip: def test_get_service_name(scan_client: ScanClient) -> None: # TODO(MarshalX): get_service_name should be removed from ScanClient? Because it exists in ScanConfig - assert scan_client.get_service_name('secret') == 'secret' - assert scan_client.get_service_name('iac') == 'iac' - assert scan_client.get_service_name('sca') == 'scans' - assert scan_client.get_service_name('sast') == 'scans' + assert scan_client.get_service_name(consts.SECRET_SCAN_TYPE) == 'secret' + assert scan_client.get_service_name(consts.INFRA_CONFIGURATION_SCAN_TYPE) == 'iac' + assert scan_client.get_service_name(consts.SCA_SCAN_TYPE) == 'scans' + assert scan_client.get_service_name(consts.SAST_SCAN_TYPE) == 'scans' @pytest.mark.parametrize('scan_type', config['scans']['supported_scans']) diff --git a/tests/test_code_scanner.py b/tests/test_code_scanner.py index d789312d..10726a65 100644 --- a/tests/test_code_scanner.py +++ b/tests/test_code_scanner.py @@ -4,6 +4,7 @@ import pytest import responses +from cycode.cli import consts from cycode.cli.commands.scan.code_scanner import ( _try_get_aggregation_report_url_if_needed, _try_get_report_url_if_needed, @@ -22,13 +23,13 @@ def test_is_relevant_file_to_scan_sca() -> None: path = os.path.join(TEST_FILES_PATH, 'package.json') - assert _is_relevant_file_to_scan('sca', path) is True + assert _is_relevant_file_to_scan(consts.SCA_SCAN_TYPE, path) is True @pytest.mark.parametrize('scan_type', config['scans']['supported_scans']) def test_try_get_report_url_if_needed_return_none(scan_type: str, scan_client: ScanClient) -> None: scan_id = uuid4().hex - result = _try_get_report_url_if_needed(scan_client, False, scan_id, 'secret') + result = _try_get_report_url_if_needed(scan_client, False, scan_id, consts.SECRET_SCAN_TYPE) assert result is None From a0c04d3e184b8c131abfaece843b8ad506ba294e Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Mon, 24 Feb 2025 16:02:06 +0100 Subject: [PATCH 141/257] CM-45171 - Bump actions/upload-artifact (#280) --- .github/workflows/build_executable.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 717b18e4..44c9a02a 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -193,7 +193,7 @@ jobs: run: echo "ARTIFACT_NAME=$(./process_executable_file.py dist/cycode-cli)" >> $GITHUB_ENV - name: Upload files as artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: ${{ env.ARTIFACT_NAME }} path: dist From f893e08338294fb91660c20fc0630675a0ff221b Mon Sep 17 00:00:00 2001 From: morsa4406 <104304449+morsa4406@users.noreply.github.com> Date: Mon, 24 Feb 2025 17:26:32 +0200 Subject: [PATCH 142/257] CM-44581 - Add support for running Gradle restore command on all subprojects (#278) --- cycode/cli/commands/scan/scan_command.py | 12 +++++ cycode/cli/consts.py | 2 + .../sca/maven/restore_gradle_dependencies.py | 48 +++++++++++++++++-- cycode/cli/utils/scan_batch.py | 3 +- 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/cycode/cli/commands/scan/scan_command.py b/cycode/cli/commands/scan/scan_command.py index 37b0a227..95259f4a 100644 --- a/cycode/cli/commands/scan/scan_command.py +++ b/cycode/cli/commands/scan/scan_command.py @@ -13,6 +13,7 @@ from cycode.cli.consts import ( ISSUE_DETECTED_STATUS_CODE, NO_ISSUES_STATUS_CODE, + SCA_GRADLE_ALL_SUB_PROJECTS_FLAG, SCA_SKIP_RESTORE_DEPENDENCIES_FLAG, ) from cycode.cli.models import Severity @@ -110,6 +111,15 @@ type=bool, required=False, ) +@click.option( + f'--{SCA_GRADLE_ALL_SUB_PROJECTS_FLAG}', + is_flag=True, + default=False, + help='When specified, Cycode will run gradle restore command for all sub projects. ' + 'Should run from root project directory ONLY!', + type=bool, + required=False, +) @click.pass_context def scan_command( context: click.Context, @@ -124,6 +134,7 @@ def scan_command( report: bool, no_restore: bool, sync: bool, + gradle_all_sub_projects: bool, ) -> int: """Scans for Secrets, IaC, SCA or SAST violations.""" add_breadcrumb('scan') @@ -145,6 +156,7 @@ def scan_command( context.obj['monitor'] = monitor context.obj['report'] = report context.obj[SCA_SKIP_RESTORE_DEPENDENCIES_FLAG] = no_restore + context.obj[SCA_GRADLE_ALL_SUB_PROJECTS_FLAG] = gradle_all_sub_projects _sca_scan_to_context(context, sca_scan) diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 3640d82a..558f5b7b 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -224,3 +224,5 @@ SCA_SHORTCUT_DEPENDENCY_PATHS = 2 SCA_SKIP_RESTORE_DEPENDENCIES_FLAG = 'no-restore' + +SCA_GRADLE_ALL_SUB_PROJECTS_FLAG = 'gradle-all-sub-projects' diff --git a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py index 04fc6b9c..85dc9e20 100644 --- a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py @@ -1,28 +1,70 @@ import os -from typing import List +import re +from typing import List, Optional, Set import click +from cycode.cli.consts import SCA_GRADLE_ALL_SUB_PROJECTS_FLAG from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_path_from_context +from cycode.cli.utils.shell_executor import shell BUILD_GRADLE_FILE_NAME = 'build.gradle' BUILD_GRADLE_KTS_FILE_NAME = 'build.gradle.kts' BUILD_GRADLE_DEP_TREE_FILE_NAME = 'gradle-dependencies-generated.txt' +BUILD_GRADLE_ALL_PROJECTS_TIMEOUT = 180 +BUILD_GRADLE_ALL_PROJECTS_COMMAND = ['gradle', 'projects'] +ALL_PROJECTS_REGEX = r"[+-]{3} Project '(.*?)'" class RestoreGradleDependencies(BaseRestoreDependencies): - def __init__(self, context: click.Context, is_git_diff: bool, command_timeout: int) -> None: + def __init__( + self, context: click.Context, is_git_diff: bool, command_timeout: int, projects: Optional[Set[str]] = None + ) -> None: super().__init__(context, is_git_diff, command_timeout, create_output_file_manually=True) + if projects is None: + projects = set() + self.projects = self.get_all_projects() if self.is_gradle_sub_projects() else projects + + def is_gradle_sub_projects(self) -> bool: + return self.context.obj.get(SCA_GRADLE_ALL_SUB_PROJECTS_FLAG) def is_project(self, document: Document) -> bool: return document.path.endswith(BUILD_GRADLE_FILE_NAME) or document.path.endswith(BUILD_GRADLE_KTS_FILE_NAME) def get_commands(self, manifest_file_path: str) -> List[List[str]]: - return [['gradle', 'dependencies', '-b', manifest_file_path, '-q', '--console', 'plain']] + return ( + self.get_commands_for_sub_projects(manifest_file_path) + if self.is_gradle_sub_projects() + else [['gradle', 'dependencies', '-b', manifest_file_path, '-q', '--console', 'plain']] + ) def get_lock_file_name(self) -> str: return BUILD_GRADLE_DEP_TREE_FILE_NAME def verify_restore_file_already_exist(self, restore_file_path: str) -> bool: return os.path.isfile(restore_file_path) + + def get_working_directory(self, document: Document) -> Optional[str]: + return get_path_from_context(self.context) if self.is_gradle_sub_projects() else None + + def get_all_projects(self) -> Set[str]: + projects_output = shell( + command=BUILD_GRADLE_ALL_PROJECTS_COMMAND, + timeout=BUILD_GRADLE_ALL_PROJECTS_TIMEOUT, + working_directory=get_path_from_context(self.context), + ) + + projects = re.findall(ALL_PROJECTS_REGEX, projects_output) + + return set(projects) + + def get_commands_for_sub_projects(self, manifest_file_path: str) -> List[List[str]]: + project_name = os.path.basename(os.path.dirname(manifest_file_path)) + project_name = f':{project_name}' + return ( + [['gradle', f'{project_name}:dependencies', '-q', '--console', 'plain']] + if project_name in self.projects + else [] + ) diff --git a/cycode/cli/utils/scan_batch.py b/cycode/cli/utils/scan_batch.py index 1ecfcf49..3d2d83dc 100644 --- a/cycode/cli/utils/scan_batch.py +++ b/cycode/cli/utils/scan_batch.py @@ -50,7 +50,8 @@ def run_parallel_batched_scan( progress_bar: 'BaseProgressBar', ) -> Tuple[Dict[str, 'CliError'], List['LocalScanResult']]: max_size = consts.SCAN_BATCH_MAX_SIZE_IN_BYTES.get(scan_type, consts.DEFAULT_SCAN_BATCH_MAX_SIZE_IN_BYTES) - batches = split_documents_into_batches(documents, max_size) + + batches = [documents] if scan_type == consts.SCA_SCAN_TYPE else split_documents_into_batches(documents, max_size) progress_bar.set_section_length(ScanProgressBarSection.SCAN, len(batches)) # * 3 # TODO(MarshalX): we should multiply the count of batches in SCAN section because each batch has 3 steps: From 56c039665aa2c781fb33a0ecc8229c55fff13266 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 25 Feb 2025 14:55:14 +0100 Subject: [PATCH 143/257] CM-45223 - Build docker image on pull requests (#281) --- .github/workflows/docker-image.yml | 46 ++++++++++++++++++++++++++++-- Dockerfile | 6 ++-- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 1db2b804..42467e02 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -1,7 +1,8 @@ -name: Build and Publish Docker Image. On dispatch event build the latest tag and push to Docker Hub +name: Build Docker Image. On tag creation push to Docker Hub. On dispatch event build the latest tag and push to Docker Hub on: workflow_dispatch: + pull_request: push: tags: [ 'v*.*.*' ] @@ -11,7 +12,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 @@ -26,6 +27,36 @@ jobs: run: | git checkout ${{ steps.latest_tag.outputs.LATEST_TAG }} + - name: Set up Python 3.8 + uses: actions/setup-python@v5 + with: + python-version: '3.8' + + - name: Load cached Poetry setup + id: cached_poetry + uses: actions/cache@v4 + with: + path: ~/.local + key: poetry-ubuntu-0 # increment to reset cache + + - name: Setup Poetry + if: steps.cached_poetry.outputs.cache-hit != 'true' + uses: snok/install-poetry@v1 + with: + version: 1.8.3 + + - name: Add Poetry to PATH + run: echo "$HOME/.local/bin" >> $GITHUB_PATH + + - name: Install Poetry Plugin + run: poetry self add "poetry-dynamic-versioning[plugin]" + + - name: Get CLI Version + id: cli_version + run: | + echo "::debug::Package version: $(poetry version --short)" + echo "CLI_VERSION=$(poetry version --short)" >> $GITHUB_OUTPUT + - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -40,9 +71,20 @@ jobs: - name: Build and push id: docker_build + if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') }} uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm64 push: true tags: cycodehq/cycode_cli:${{ steps.latest_tag.outputs.LATEST_TAG }},cycodehq/cycode_cli:latest + + - name: Verify build + id: docker_verify_build + if: ${{ github.event_name != 'workflow_dispatch' && !startsWith(github.ref, 'refs/tags/v') }} + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: false + tags: cycodehq/cycode_cli:${{ steps.cli_version.outputs.CLI_VERSION }} diff --git a/Dockerfile b/Dockerfile index 1b3a5815..641b829d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,12 @@ -FROM python:3.12.6-alpine3.20 AS base +FROM python:3.12.9-alpine3.21 AS base WORKDIR /usr/cycode/app -RUN apk add git=2.45.2-r0 +RUN apk add git=2.47.2-r0 FROM base AS builder ENV POETRY_VERSION=1.8.3 # deps are required to build cffi -RUN apk add --no-cache --virtual .build-deps gcc=13.2.1_git20240309-r0 libffi-dev=3.4.6-r0 musl-dev=1.2.5-r0 && \ +RUN apk add --no-cache --virtual .build-deps gcc=14.2.0-r4 libffi-dev=3.4.6-r0 musl-dev=1.2.5-r9 && \ pip install --no-cache-dir "poetry==$POETRY_VERSION" "poetry-dynamic-versioning[plugin]" && \ apk del .build-deps gcc libffi-dev musl-dev From 84b8e34afb18134a917b3af90f671a26774a29db Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Thu, 27 Feb 2025 10:55:00 +0100 Subject: [PATCH 144/257] CM-45334 - Review and update README.md (#282) --- README.md | 345 ++++++++++++++++++++++++++---------------------------- 1 file changed, 163 insertions(+), 182 deletions(-) diff --git a/README.md b/README.md index 98d22d93..189d69f2 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Cycode CLI User Guide -The Cycode Command Line Interface (CLI) is an application you can install on your local machine which can scan your locally stored repositories for any secrets or infrastructure as code misconfigurations. +The Cycode Command Line Interface (CLI) is an application you can install locally to scan your repositories for secrets, infrastructure as code misconfigurations, software composition analysis vulnerabilities, and static application security testing issues. -This guide will guide you through both installation and usage. +This guide walks you through both installation and usage. # Table of Contents @@ -15,23 +15,23 @@ This guide will guide you through both installation and usage. 1. [On Unix/Linux](#on-unixlinux) 2. [On Windows](#on-windows) 2. [Install Pre-Commit Hook](#install-pre-commit-hook) -3. [Cycode Command](#cycode-cli-commands) +3. [Cycode CLI Commands](#cycode-cli-commands) 4. [Scan Command](#scan-command) 1. [Running a Scan](#running-a-scan) - 1. [Repository Scan](#repository-scan) + 1. [Options](#options) + 1. [Severity Threshold](#severity-option) + 2. [Monitor](#monitor-option) + 3. [Report](#report-option) + 4. [Package Vulnerabilities](#package-vulnerabilities-option) + 5. [License Compliance](#license-compliance-option) + 6. [Lock Restore](#lock-restore-option) + 2. [Repository Scan](#repository-scan) 1. [Branch Option](#branch-option) - 2. [Monitor Option](#monitor-option) - 3. [Report Option](#report-option) - 4. [Package Vulnerabilities Scan](#package-vulnerabilities-option) - 1. [License Compliance Option](#license-compliance-option) - 2. [Severity Threshold](#severity-threshold) - 5. [Path Scan](#path-scan) + 3. [Path Scan](#path-scan) 1. [Terraform Plan Scan](#terraform-plan-scan) - 6. [Commit History Scan](#commit-history-scan) + 4. [Commit History Scan](#commit-history-scan) 1. [Commit Range Option](#commit-range-option) - 7. [Pre-Commit Scan](#pre-commit-scan) - 8. [Lock Restore Options](#lock-restore-options) - 1. [SBT Scan](#sbt-scan) + 5. [Pre-Commit Scan](#pre-commit-scan) 2. [Scan Results](#scan-results) 1. [Show/Hide Secrets](#showhide-secrets) 2. [Soft Fail](#soft-fail) @@ -47,7 +47,7 @@ This guide will guide you through both installation and usage. 3. [Ignoring a Path](#ignoring-a-path) 4. [Ignoring a Secret, IaC, or SCA Rule](#ignoring-a-secret-iac-sca-or-sast-rule) 5. [Ignoring a Package](#ignoring-a-package) - 6. [Ignoring using config file](#ignoring-using-config-file) + 6. [Ignoring via a config file](#ignoring-via-a-config-file) 5. [Report command](#report-command) 1. [Generating SBOM Report](#generating-sbom-report) 6. [Syntax Help](#syntax-help) @@ -56,7 +56,7 @@ This guide will guide you through both installation and usage. - The Cycode CLI application requires Python version 3.8 or later. - Use the [`cycode auth` command](#using-the-auth-command) to authenticate to Cycode with the CLI - - Alternatively, you can obtain a Cycode Client ID and Client Secret Key by following the steps detailed in the [Service Account Token](https://docs.cycode.com/reference/creating-a-service-account-access-token) and [Personal Access Token](https://docs.cycode.com/reference/creating-a-personal-access-token-1) pages, which contain details on obtaining these values. + - Alternatively, you can get a Cycode Client ID and Client Secret Key by following the steps detailed in the [Service Account Token](https://docs.cycode.com/docs/en/service-accounts) and [Personal Access Token](https://docs.cycode.com/v1/docs/managing-personal-access-tokens) pages, which contain details on getting these values. # Installation @@ -73,8 +73,17 @@ To install the Cycode CLI application on your local machine, perform the followi 2. Execute one of the following commands: - - `pip3 install cycode` - to install from PyPI - - `brew install cycode` - to install from Homebrew + - To install from [PyPI](https://pypi.org/project/cycode/): + + ```bash + pip3 install cycode + ``` + + - To install from [Homebrew](https://formulae.brew.sh/formula/cycode): + + ```bash + brew install cycode + ``` 3. Navigate to the top directory of the local repository you wish to scan. @@ -95,30 +104,28 @@ To install the Cycode CLI application on your local machine, perform the followi 2. A browser window will appear, asking you to log into Cycode (as seen below): - ![Cycode login](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/cycode_login.png) + Cycode login 3. Enter your login credentials on this page and log in. 4. You will eventually be taken to the page below, where you'll be asked to choose the business group you want to authorize Cycode with (if applicable): - ![authorize CLI](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/authorize_cli.png) + authorize CLI > [!NOTE] - > This will be the default method for authenticating with the Cycode CLI. + > This will be the default method for authenticating with the Cycode CLI. 5. Click the **Allow** button to authorize the Cycode CLI on the selected business group. - ![allow CLI](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/allow_cli.png) + allow CLI -6. Once completed, you'll see the following screen, if it was selected successfully: +6. Once completed, you'll see the following screen if it was selected successfully: - ![successfully auth](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/successfully_auth.png) + successfully auth 7. In the terminal/command line screen, you will see the following when exiting the browser window: - ```bash - Successfully logged into cycode - ``` + `Successfully logged into cycode` ### Using the Configure Command @@ -127,46 +134,36 @@ To install the Cycode CLI application on your local machine, perform the followi 1. Type the following command into your terminal/command line window: - `cycode configure` + ```bash + cycode configure + ``` 2. Enter your Cycode API URL value (you can leave blank to use default value). - ```bash - Cycode API URL [https://api.cycode.com]: https://api.onpremise.com - ``` + `Cycode API URL [https://api.cycode.com]: https://api.onpremise.com` 3. Enter your Cycode APP URL value (you can leave blank to use default value). - ```bash - Cycode APP URL [https://app.cycode.com]: https://app.onpremise.com - ``` + `Cycode APP URL [https://app.cycode.com]: https://app.onpremise.com` 4. Enter your Cycode Client ID value. - ```bash - Cycode Client ID []: 7fe5346b-xxxx-xxxx-xxxx-55157625c72d - ``` + `Cycode Client ID []: 7fe5346b-xxxx-xxxx-xxxx-55157625c72d` 5. Enter your Cycode Client Secret value. - ```bash - Cycode Client Secret []: c1e24929-xxxx-xxxx-xxxx-8b08c1839a2e - ``` + `Cycode Client Secret []: c1e24929-xxxx-xxxx-xxxx-8b08c1839a2e` 6. If the values were entered successfully, you'll see the following message: - ```bash - Successfully configured CLI credentials! - ``` + `Successfully configured CLI credentials!` or/and - ```bash - Successfully configured Cycode URLs! - ``` + `Successfully configured Cycode URLs!` If you go into the `.cycode` folder under your user folder, you'll find these credentials were created and placed in the `credentials.yaml` file in that folder. -And the URLs were placed in the `config.yaml` file in that folder. +The URLs were placed in the `config.yaml` file in that folder. ### Add to Environment Variables @@ -174,6 +171,11 @@ And the URLs were placed in the `config.yaml` file in that folder. ```bash export CYCODE_CLIENT_ID={your Cycode ID} +``` + +and + +```bash export CYCODE_CLIENT_SECRET={your Cycode Secret Key} ``` @@ -181,22 +183,22 @@ export CYCODE_CLIENT_SECRET={your Cycode Secret Key} 1. From the Control Panel, navigate to the System menu: - ![](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/image1.png) + system menu 2. Next, click Advanced system settings: - ![](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/image2.png) + advanced system setting 3. In the System Properties window that opens, click the Environment Variables button: - ![](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/image3.png) + environments variables button 4. Create `CYCODE_CLIENT_ID` and `CYCODE_CLIENT_SECRET` variables with values matching your ID and Secret Key, respectively: - ![](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/image4.png) + environment variables window + +5. Insert the `cycode.exe` into the path to complete the installation. -5. Insert the cycode.exe into the path to complete the installation. - ## Install Pre-Commit Hook Cycode’s pre-commit hook can be set up within your local repository so that the Cycode CLI application will identify any issues with your code automatically before you commit it to your codebase. @@ -208,7 +210,9 @@ Perform the following steps to install the pre-commit hook: 1. Install the pre-commit framework (Python 3.8 or higher must be installed): - `pip3 install pre-commit` + ```bash + pip3 install pre-commit + ``` 2. Navigate to the top directory of the local Git repository you wish to configure. @@ -217,7 +221,7 @@ Perform the following steps to install the pre-commit hook: ```yaml repos: - repo: https://github.com/cycodehq/cycode-cli - rev: v1.11.0 + rev: v2.3.0 hooks: - id: cycode stages: @@ -229,7 +233,7 @@ Perform the following steps to install the pre-commit hook: ```yaml repos: - repo: https://github.com/cycodehq/cycode-cli - rev: v1.11.0 + rev: v2.3.0 hooks: - id: cycode stages: @@ -241,15 +245,19 @@ Perform the following steps to install the pre-commit hook: 5. Install Cycode’s hook: - `pre-commit install` + ```bash + pre-commit install + ``` A successful hook installation will result in the message: `Pre-commit installed at .git/hooks/pre-commit`. 6. Keep the pre-commit hook up to date: - `pre-commit autoupdate` + ```bash + pre-commit autoupdate + ``` - It will automatically bump "rev" in ".pre-commit-config.yaml" to the latest available version of Cycode CLI. + It will automatically bump `rev` in `.pre-commit-config.yaml` to the latest available version of Cycode CLI. > [!NOTE] > Trigger happens on `git commit` command. @@ -259,20 +267,23 @@ Perform the following steps to install the pre-commit hook: The following are the options and commands available with the Cycode CLI application: -| Option | Description | -|--------------------------------------|--------------------------------------------------------------------| -| `-o`, `--output [text\|json\|table]` | Specify the output (`text`/`json`/`table`). The default is `text`. | -| `-v`, `--verbose` | Show detailed logs. | -| `--help` | Show options for given command. | - -| Command | Description | -|-------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------| -| [auth](#using-the-auth-command) | Authenticates your machine to associate CLI with your Cycode account. | -| [configure](#using-the-configure-command) | Initial command to authenticate your CLI client with Cycode using client ID and client secret. | -| [ignore](#ignoring-scan-results) | Ignore a specific value, path or rule ID. | -| [scan](#running-a-scan) | Scan content for secrets/IaC/SCA/SAST violations. You need to specify which scan type: `ci`/`commit_history`/`path`/`repository`/etc. | -| [report](#report-command) | Generate report for SCA SBOM. | -| version | Show the version and exit. | +| Option | Description | +|--------------------------------------|------------------------------------------------------------------------| +| `-v`, `--verbose` | Show detailed logs. | +| `--no-progress-meter` | Do not show the progress meter. | +| `--no-update-notifier` | Do not check CLI for updates. | +| `-o`, `--output [text\|json\|table]` | Specify the output (`text`/`json`/`table`). The default is `text`. | +| `--user-agent TEXT` | Characteristic JSON object that lets servers identify the application. | +| `--help` | Show options for given command. | + +| Command | Description | +|-------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------| +| [auth](#using-the-auth-command) | Authenticate your machine to associate the CLI with your Cycode account. | +| [configure](#using-the-configure-command) | Initial command to configure your CLI client authentication. | +| [ignore](#ignoring-scan-results) | Ignores a specific value, path or rule ID. | +| [scan](#running-a-scan) | Scan the content for Secrets/IaC/SCA/SAST violations. You`ll need to specify which scan type to perform: commit_history/path/repository/etc. | +| [report](#report-command) | Generate report. You`ll need to specify which report type to perform. | +| status | Show the CLI status and exit. | # Scan Command @@ -282,16 +293,18 @@ The Cycode CLI application offers several types of scans so that you can choose | Option | Description | |------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `-t, --scan-type [secret\|iac\|sca\|sast]` | Specify the scan you wish to execute (`secret`/`iac`/`sca`/`sast`), the default is `secret` | -| `--secret TEXT` | Specify a Cycode client secret for this specific scan execution | -| `--client-id TEXT` | Specify a Cycode client ID for this specific scan execution | +| `-t, --scan-type [secret\|iac\|sca\|sast]` | Specify the scan you wish to execute (`secret`/`iac`/`sca`/`sast`), the default is `secret`. | +| `--secret TEXT` | Specify a Cycode client secret for this specific scan execution. | +| `--client-id TEXT` | Specify a Cycode client ID for this specific scan execution. | | `--show-secret BOOLEAN` | Show secrets in plain text. See [Show/Hide Secrets](#showhide-secrets) section for more details. | | `--soft-fail BOOLEAN` | Run scan without failing, always return a non-error status code. See [Soft Fail](#soft-fail) section for more details. | | `--severity-threshold [INFO\|LOW\|MEDIUM\|HIGH\|CRITICAL]` | Show only violations at the specified level or higher. | -| `--sca-scan` | Specify the SCA scan you wish to execute (`package-vulnerabilities`/`license-compliance`). The default is both | +| `--sca-scan` | Specify the SCA scan you wish to execute (`package-vulnerabilities`/`license-compliance`). The default is both. | | `--monitor` | When specified, the scan results will be recorded in the knowledge graph. Please note that when working in `monitor` mode, the knowledge graph will not be updated as a result of SCM events (Push, Repo creation). (Supported for SCA scan type only). | -| `--report` | When specified, a violations report will be generated. A URL link to the report will be printed as an output to the command execution | +| `--report` | When specified, a violations report will be generated. A URL link to the report will be printed as an output to the command execution. | | `--no-restore` | When specified, Cycode will not run restore command. Will scan direct dependencies ONLY! | +| `--sync` | Run scan synchronously (the default is asynchronous). | +| `--gradle-all-sub-projects` | When specified, Cycode will run gradle restore command for all sub projects. Should run from root project directory ONLY! | | `--help` | Show options for given command. | | Command | Description | @@ -301,37 +314,17 @@ The Cycode CLI application offers several types of scans so that you can choose | [pre_commit](#pre-commit-scan) | Use this command to scan the content that was not committed yet | | [repository](#repository-scan) | Scan git repository including its history | -### Repository Scan - -A repository scan examines an entire local repository for any exposed secrets or insecure misconfigurations. This more holistic scan type looks at everything: the current state of your repository and its commit history. It will look not only for secrets that are currently exposed within the repository but previously deleted secrets as well. - -To execute a full repository scan, execute the following: - -`cycode scan repository {{path}}` - -For example, consider a scenario in which you want to scan your repository stored in `~/home/git/codebase`. You could then execute the following: - -`cycode scan repository ~/home/git/codebase` - -The following option is available for use with this command: - -| Option | Description | -|---------------------|--------------------------------------------------------| -| `-b, --branch TEXT` | Branch to scan, if not set scanning the default branch | - -#### Branch Option - -To scan a specific branch of your local repository, add the argument `-b` (alternatively, `--branch`) followed by the name of the branch you wish to scan. +### Options -Consider the previous example. If you wanted to only scan a branch named `dev`, you could execute the following: +#### Severity Option -`cycode scan repository ~/home/git/codebase -b dev` +To limit the results of the scan to a specific severity threshold, add the argument `--severity-threshold` to the scan command. -or: +The following command will scan the repository for policy violations that have severity of Medium or higher: -`cycode scan repository ~/home/git/codebase --branch dev` +`cycode scan --severity-threshold MEDIUM repository ~/home/git/codebase` -### Monitor Option +#### Monitor Option > [!NOTE] > This option is only available to SCA scans. @@ -342,16 +335,12 @@ Consider the following example. The following command will scan the repository f `cycode scan -t sca --monitor repository ~/home/git/codebase` -or: - -`cycode scan --scan-type sca --monitor repository ~/home/git/codebase` - When using this option, the scan results from this scan will appear in the knowledge graph, which can be found [here](https://app.cycode.com/query-builder). > [!WARNING] > You must be an `owner` or an `admin` in Cycode to view the knowledge graph page. -### Report Option +#### Report Option > [!NOTE] > This option is not available to IaC scans. @@ -359,26 +348,21 @@ When using this option, the scan results from this scan will appear in the knowl To push scan results tied to the [SCA policies](https://docs.cycode.com/docs/sca-policies) found in the Repository scan to Cycode, add the argument `--report` to the scan command. `cycode scan -t sca --report repository ~/home/git/codebase` -`cycode scan -t secret --report repository ~/home/git/codebase` - -or: -`cycode scan --scan-type sca --report repository ~/home/git/codebase` -`cycode scan --scan-type secret --report repository ~/home/git/codebase` +In the same way, you can push scan results of Secrets and SAST scans to Cycode by adding the `--report` option to the scan command. When using this option, the scan results from this scan will appear in the On-Demand Scans section of Cycode. To get to this page, click the link that appears after the printed results: -> :warning: **NOTE**
+> [!WARNING] > You must be an `owner` or an `admin` in Cycode to view this page. ![cli-report](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/sca_report_url.png) - The report page will look something like below: ![](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/scan_details.png) -### Package Vulnerabilities Option +#### Package Vulnerabilities Option > [!NOTE] > This option is only available to SCA scans. @@ -389,10 +373,6 @@ Consider the previous example. If you wanted to only run an SCA scan on package `cycode scan -t sca --sca-scan package-vulnerabilities repository ~/home/git/codebase` -or: - -`cycode scan --scan-type sca --sca-scan package-vulnerabilities repository ~/home/git/codebase` - #### License Compliance Option > [!NOTE] @@ -404,24 +384,46 @@ Consider the previous example. If you wanted to only scan a branch named `dev`, `cycode scan -t sca --sca-scan license-compliance repository ~/home/git/codebase -b dev` -or: - -`cycode scan --scan-type sca --sca-scan license-compliance repository ~/home/git/codebase` - -#### Severity Threshold +#### Lock Restore Option > [!NOTE] > This option is only available to SCA scans. -To limit the results of the `sca` scan to a specific severity threshold, add the argument `--severity-threshold` to the scan command. +We use sbt-dependency-lock plugin to restore the lock file for SBT projects. +To disable lock restore in use `--no-restore` option. + +Prerequisites: +* `sbt-dependency-lock` plugin: Install the plugin by adding the following line to `project/plugins.sbt`: + + ```text + addSbtPlugin("software.purpledragon" % "sbt-dependency-lock" % "1.5.1") + ``` + +### Repository Scan + +A repository scan examines an entire local repository for any exposed secrets or insecure misconfigurations. This more holistic scan type looks at everything: the current state of your repository and its commit history. It will look not only for secrets that are currently exposed within the repository but previously deleted secrets as well. + +To execute a full repository scan, execute the following: + +`cycode scan repository {{path}}` -Consider the following example. The following command will scan the repository for SCA policy violations that have a severity of Medium or higher: +For example, consider a scenario in which you want to scan your repository stored in `~/home/git/codebase`. You could then execute the following: + +`cycode scan repository ~/home/git/codebase` + +The following option is available for use with this command: + +| Option | Description | +|---------------------|--------------------------------------------------------| +| `-b, --branch TEXT` | Branch to scan, if not set scanning the default branch | -`cycode scan -t sca --severity-threshold MEDIUM repository ~/home/git/codebase` +#### Branch Option -or: +To scan a specific branch of your local repository, add the argument `-b` (alternatively, `--branch`) followed by the name of the branch you wish to scan. -`cycode scan --scan-type sca --severity-threshold MEDIUM repository ~/home/git/codebase` +Consider the previous example. If you wanted to only scan a branch named `dev`, you could execute the following: + +`cycode scan repository ~/home/git/codebase -b dev` ### Path Scan @@ -435,7 +437,6 @@ For example, consider a scenario in which you want to scan the directory located `cycode scan path ~/home/git/codebase` - #### Terraform Plan Scan Cycode CLI supports Terraform plan scanning (supporting Terraform 0.12 and later) @@ -480,34 +481,21 @@ The following options are available for use with this command: #### Commit Range Option -The commit history scan, by default, examines the repository’s entire commit history, all the way back to the initial commit. You can instead limit the scan to a specific commit range by adding the argument `--commit_range` followed by the name you specify. +The commit history scan, by default, examines the repository’s entire commit history, all the way back to the initial commit. You can instead limit the scan to a specific commit range by adding the argument `--commit_range` (`-r`) followed by the name you specify. -Consider the previous example. If you wanted to scan only specific commits on your repository, you could execute the following: +Consider the previous example. If you wanted to scan only specific commits in your repository, you could execute the following: `cycode scan commit_history -r {{from-commit-id}}...{{to-commit-id}} ~/home/git/codebase` -OR - -`cycode scan commit_history --commit_range {{from-commit-id}}...{{to-commit-id}} ~/home/git/codebase` - ### Pre-Commit Scan -A pre-commit scan automatically identifies any issues before you commit changes to your repository. There is no need to manually execute this scan; simply configure the pre-commit hook as detailed under the Installation section of this guide. +A pre-commit scan automatically identifies any issues before you commit changes to your repository. There is no need to manually execute this scan; configure the pre-commit hook as detailed under the Installation section of this guide. -After your install the pre-commit hook and, you may, on occasion, wish to skip scanning during a specific commit. Simply add the following to your `git` command to skip scanning for a single commit: - -`SKIP=cycode git commit -m ` - -### Lock Restore Options - -#### SBT Scan - -We use sbt-dependency-lock plugin to restore the lock file for SBT projects. -To disable lock restore in use `--no-restore` option. +After installing the pre-commit hook, you may occasionally wish to skip scanning during a specific commit. To do this, add the following to your `git` command to skip scanning for a single commit: -Prerequisites -* sbt-dependency-lock Plugin: Install the plugin by adding the following line to `project/plugins.sbt`: -`addSbtPlugin("software.purpledragon" % "sbt-dependency-lock" % "1.5.1")` +```bash +SKIP=cycode git commit -m ` +``` ## Scan Results @@ -527,19 +515,19 @@ Secret SHA: a44081db3296c84b82d12a35c446a3cba19411dddfa0380134c75f7b3973bff0 2 | \ No newline at end of file ``` -In the event an issue is found, review the file in question for the specific line highlighted by the result message. Implement any changes required to resolve the issue, then execute the scan again. +If an issue is found, review the file in question for the specific line highlighted by the result message. Implement any changes required to resolve the issue, then execute the scan again. ### Show/Hide Secrets In the above example, a secret was found in the file `secret_test`, located in the subfolder `cli`. The second part of the message shows the specific line the secret appears in, which in this case is a value assigned to `googleApiKey`. -Note how the above example obscures the actual secret value, replacing most of the secret with asterisks. Scans obscure secrets by default, but you may optionally disable this feature in order to view the full secret (assuming the machine you are viewing the scan result on is sufficiently secure from prying eyes). +Note how the above example obscures the actual secret value, replacing most of the secret with asterisks. Scans obscure secrets by default, but you may optionally disable this feature to view the full secret (assuming the machine you are viewing the scan result on is sufficiently secure from prying eyes). -To disable secret obfuscation, add the `--show-secret` argument to any type of scan, then assign it a `1` value to show the full secret in the result message, or `0` to hide the secret (which is done by default). +To disable secret obfuscation, add the `--show-secret` argument to any type of scan. In the following example, a Path Scan is executed against the `cli` subdirectory with the option enabled to display any secrets found in full: -`cycode scan --show-secret=1 path ./cli` +`cycode scan --show-secret path ./cli` The result would then not be obfuscated: @@ -604,13 +592,13 @@ Secret SHA: a44081db3296c84b82d12a35c446a3cba19411dddfa0380134c75f7b3973bff0 ### Company’s Custom Remediation Guidelines -If your company has set custom remediation guidelines in the relevant policy via the Cycode portal, you'll see a field for “Company Guidelines” that contains the remediation guidelines you added. Note that if you haven't added any company guideline, this field will not appear in the CLI tool. +If your company has set custom remediation guidelines in the relevant policy via the Cycode portal, you'll see a field for “Company Guidelines” that contains the remediation guidelines you added. Note that if you haven't added any company guidelines, this field will not appear in the CLI tool. ## Ignoring Scan Results -Ignore rules can be added to ignore specific secret values, specific SHA512 values, specific paths, and specific Cycode secret and IaC rule IDs. This will cause the scan to not alert these values. The ignore rules are written and saved locally in the `./.cycode/config.yaml` file. +Ignore rules can be added to ignore specific secret values, specific SHA512 values, specific paths, and specific Cycode secret and IaC rule IDs. This will cause the scan to not alert these values. The ignoring rules are written and saved locally in the `./.cycode/config.yaml` file. -> :warning: **Warning**
+> [!WARNING] > Adding values to be ignored should be done with careful consideration of the values, paths, and policies to ensure that the scans will pick up true positives. The following are the options available for the `cycode ignore` command: @@ -622,8 +610,9 @@ The following are the options available for the `cycode ignore` command: | `--by-path TEXT` | Avoid scanning a specific path. Need to specify scan type. See [Ignoring a Path](#ignoring-a-path) for more details. | | `--by-rule TEXT` | Ignore scanning a specific secret rule ID/IaC rule ID/SCA rule ID. See [Ignoring a Secret or Iac Rule](#ignoring-a-secret-iac-sca-or-sast-rule) for more details. | | `--by-package TEXT` | Ignore scanning a specific package version while running an SCA scan. Expected pattern - `name@version`. See [Ignoring a Package](#ignoring-a-package) for more details. | -| `-t, --scan-type [secret\|iac\|sca\|sast]` | Specify the scan you wish to execute (`secret`/`iac`/`sca`/`sast`), The default value is `secret` | -| `-g, --global` | Add an ignore rule and update it in the global `.cycode` config file | +| `--by-cve TEXT` | Ignore scanning a specific CVE while running an SCA scan. Expected pattern: CVE-YYYY-NNN. | +| `-t, --scan-type [secret\|iac\|sca\|sast]` | Specify the scan you wish to execute (`secret`/`iac`/`sca`/`sast`). The default value is `secret`. | +| `-g, --global` | Add an ignore rule and update it in the global `.cycode` config file. | In the following example, a pre-commit scan runs and finds the following: @@ -667,10 +656,6 @@ To ignore a specific path for either secret, IaC, or SCA scans, you will need to `cycode ignore -t {{scan-type}} --by-path {{path}}` -OR - -`cycode ignore --scan-type {{scan-type}} --by-path {{path}}` - In the example at the top of this section, the command to ignore a specific path for a secret is as follows: `cycode ignore -t secret --by-path ~/home/my-repo/config` @@ -695,25 +680,21 @@ To ignore a specific secret, IaC, SCA, or SAST rule, you will need to use the `- `cycode ignore -t {{scan-type}} --by-rule {{rule-ID}}` -OR - -`cycode ignore --scan-type {{scan-type}} --by-rule {{rule-ID}}` - In the example at the top of this section, the command to ignore the specific secret rule ID is as follows: -`cycode ignore --scan-type secret --by-rule ce3a4de0-9dfc-448b-a004-c538cf8b4710` +`cycode ignore -t secret --by-rule ce3a4de0-9dfc-448b-a004-c538cf8b4710` In the example above, replace the `ce3a4de0-9dfc-448b-a004-c538cf8b4710` value with the rule ID you want to ignore. In the example at the top of this section, the command to ignore the specific IaC rule ID is as follows: -`cycode ignore --scan-type iac --by-rule bdaa88e2-5e7c-46ff-ac2a-29721418c59c` +`cycode ignore -t iac --by-rule bdaa88e2-5e7c-46ff-ac2a-29721418c59c` In the example above, replace the `bdaa88e2-5e7c-46ff-ac2a-29721418c59c` value with the rule ID you want to ignore. In the example at the top of this section, the command to ignore the specific SCA rule ID is as follows: -`cycode ignore --scan-type sca --by-rule dc21bc6b-9f4f-46fb-9f92-e4327ea03f6b` +`cycode ignore -t sca --by-rule dc21bc6b-9f4f-46fb-9f92-e4327ea03f6b` In the example above, replace the `dc21bc6b-9f4f-46fb-9f92-e4327ea03f6b` value with the rule ID you want to ignore. @@ -736,7 +717,7 @@ In the example below, the command to ignore a specific SCA package is as follows In the example above, replace `pyyaml` with package name and `5.3.1` with the package version you want to ignore. -### Ignoring using config file +### Ignoring via a config file The applied ignoring rules are stored in the configuration file called `config.yaml`. This file could be easily shared between developers or even committed to remote Git. @@ -785,14 +766,15 @@ It's important to understand how CLI stores ignore rules to be able to read thes The abstract YAML structure: ```yaml exclusions: - *scanTypeName*: - *ignoringType: - - *ignoringValue1* - - *ignoringValue2* + {scanTypeName}: + {ignoringType}: + - someIgnoringValue1 + - someIgnoringValue2 ``` Possible values of `scanTypeName`: `iac`, `sca`, `sast`, `secret`. -Possible values of `ignoringType`: `paths`, `values`, `rules`, `packages`, `shas`. + +Possible values of `ignoringType`: `paths`, `values`, `rules`, `packages`, `shas`, `cves`. > [!WARNING] > Values for "ignore by value" are not stored as plain text! @@ -825,7 +807,7 @@ exclusions: ## Generating SBOM Report A software bill of materials (SBOM) is an inventory of all constituent components and software dependencies involved in the development and delivery of an application. -Using this command you can create an SBOM report for your local project or for your repository URI. +Using this command, you can create an SBOM report for your local project or for your repository URI. The following options are available for use with this command: @@ -876,7 +858,7 @@ To see the options available for a specific type of scan, enter: `cycode scan {{option}} --help` -For example, to see options available for a Path Scan, you would simply enter: +For example, to see options available for a Path Scan, you would enter: `cycode scan path --help` @@ -884,11 +866,10 @@ To see the options available for the ignore scan function, use this command: `cycode ignore --help` -To see the options available for report, use this command: +To see the options available for a report, use this command: `cycode report --help` - To see the options available for a specific type of report, enter: `cycode scan {{option}} --help` From f1f7c63a5e72fa39af28189083db833c8284f087 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 5 Mar 2025 17:01:36 +0100 Subject: [PATCH 145/257] CM-45588 - Make batching more configurable and friendly in logs (#284) --- Dockerfile | 2 +- cycode/cli/commands/scan/code_scanner.py | 2 +- cycode/cli/consts.py | 4 ++ cycode/cli/utils/scan_batch.py | 77 +++++++++++++++++++++--- 4 files changed, 76 insertions(+), 9 deletions(-) diff --git a/Dockerfile b/Dockerfile index 641b829d..8867d1f2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ FROM base AS builder ENV POETRY_VERSION=1.8.3 # deps are required to build cffi -RUN apk add --no-cache --virtual .build-deps gcc=14.2.0-r4 libffi-dev=3.4.6-r0 musl-dev=1.2.5-r9 && \ +RUN apk add --no-cache --virtual .build-deps gcc=14.2.0-r4 libffi-dev=3.4.7-r0 musl-dev=1.2.5-r9 && \ pip install --no-cache-dir "poetry==$POETRY_VERSION" "poetry-dynamic-versioning[plugin]" && \ apk del .build-deps gcc libffi-dev musl-dev diff --git a/cycode/cli/commands/scan/code_scanner.py b/cycode/cli/commands/scan/code_scanner.py index b3fddf59..5f10ffdf 100644 --- a/cycode/cli/commands/scan/code_scanner.py +++ b/cycode/cli/commands/scan/code_scanner.py @@ -171,7 +171,7 @@ def _scan_batch_thread_func(batch: List[Document]) -> Tuple[str, CliError, Local should_use_sync_flow = _should_use_sync_flow(command_scan_type, scan_type, sync_option, scan_parameters) try: - logger.debug('Preparing local files, %s', {'batch_size': len(batch)}) + logger.debug('Preparing local files, %s', {'batch_files_count': len(batch)}) zipped_documents = zip_documents(scan_type, batch) zip_file_size = zipped_documents.size scan_result = perform_scan( diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 558f5b7b..42bd1ab7 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -145,7 +145,11 @@ # scan in batches DEFAULT_SCAN_BATCH_MAX_SIZE_IN_BYTES = 9 * 1024 * 1024 SCAN_BATCH_MAX_SIZE_IN_BYTES = {SAST_SCAN_TYPE: 50 * 1024 * 1024} +SCAN_BATCH_MAX_SIZE_IN_BYTES_ENV_VAR_NAME = 'SCAN_BATCH_MAX_SIZE_IN_BYTES' + DEFAULT_SCAN_BATCH_MAX_FILES_COUNT = 1000 +SCAN_BATCH_MAX_FILES_COUNT_ENV_VAR_NAME = 'SCAN_BATCH_MAX_FILES_COUNT' + # if we increase this values, the server doesn't allow connecting (ConnectionError) SCAN_BATCH_MAX_PARALLEL_SCANS = 5 SCAN_BATCH_SCANS_PER_CPU = 1 diff --git a/cycode/cli/utils/scan_batch.py b/cycode/cli/utils/scan_batch.py index 3d2d83dc..4019b7b0 100644 --- a/cycode/cli/utils/scan_batch.py +++ b/cycode/cli/utils/scan_batch.py @@ -5,17 +5,53 @@ from cycode.cli import consts from cycode.cli.models import Document from cycode.cli.utils.progress_bar import ScanProgressBarSection +from cycode.cyclient import logger if TYPE_CHECKING: from cycode.cli.models import CliError, LocalScanResult from cycode.cli.utils.progress_bar import BaseProgressBar +def _get_max_batch_size(scan_type: str) -> int: + logger.debug( + 'You can customize the batch size by setting the environment variable "%s"', + consts.SCAN_BATCH_MAX_SIZE_IN_BYTES_ENV_VAR_NAME, + ) + + custom_size = os.environ.get(consts.SCAN_BATCH_MAX_SIZE_IN_BYTES_ENV_VAR_NAME) + if custom_size: + logger.debug('Custom batch size is set, %s', {'custom_size': custom_size}) + return int(custom_size) + + return consts.SCAN_BATCH_MAX_SIZE_IN_BYTES.get(scan_type, consts.DEFAULT_SCAN_BATCH_MAX_SIZE_IN_BYTES) + + +def _get_max_batch_files_count(_: str) -> int: + logger.debug( + 'You can customize the batch files count by setting the environment variable "%s"', + consts.SCAN_BATCH_MAX_FILES_COUNT_ENV_VAR_NAME, + ) + + custom_files_count = os.environ.get(consts.SCAN_BATCH_MAX_FILES_COUNT_ENV_VAR_NAME) + if custom_files_count: + logger.debug('Custom batch files count is set, %s', {'custom_files_count': custom_files_count}) + return int(custom_files_count) + + return consts.DEFAULT_SCAN_BATCH_MAX_FILES_COUNT + + def split_documents_into_batches( + scan_type: str, documents: List[Document], - max_size: int = consts.DEFAULT_SCAN_BATCH_MAX_SIZE_IN_BYTES, - max_files_count: int = consts.DEFAULT_SCAN_BATCH_MAX_FILES_COUNT, ) -> List[List[Document]]: + max_size = _get_max_batch_size(scan_type) + max_files_count = _get_max_batch_files_count(scan_type) + + logger.debug( + 'Splitting documents into batches, %s', + {'document_count': len(documents), 'max_batch_size': max_size, 'max_files_count': max_files_count}, + ) + batches = [] current_size = 0 @@ -23,7 +59,29 @@ def split_documents_into_batches( for document in documents: document_size = len(document.content.encode('UTF-8')) - if (current_size + document_size > max_size) or (len(current_batch) >= max_files_count): + exceeds_max_size = current_size + document_size > max_size + if exceeds_max_size: + logger.debug( + 'Going to create new batch because current batch size exceeds the limit, %s', + { + 'batch_index': len(batches), + 'current_batch_size': current_size + document_size, + 'max_batch_size': max_size, + }, + ) + + exceeds_max_files_count = len(current_batch) >= max_files_count + if exceeds_max_files_count: + logger.debug( + 'Going to create new batch because current batch files count exceeds the limit, %s', + { + 'batch_index': len(batches), + 'current_batch_files_count': len(current_batch), + 'max_batch_files_count': max_files_count, + }, + ) + + if exceeds_max_size or exceeds_max_files_count: batches.append(current_batch) current_batch = [document] @@ -35,6 +93,8 @@ def split_documents_into_batches( if current_batch: batches.append(current_batch) + logger.debug('Documents were split into batches %s', {'batches_count': len(batches)}) + return batches @@ -49,9 +109,8 @@ def run_parallel_batched_scan( documents: List[Document], progress_bar: 'BaseProgressBar', ) -> Tuple[Dict[str, 'CliError'], List['LocalScanResult']]: - max_size = consts.SCAN_BATCH_MAX_SIZE_IN_BYTES.get(scan_type, consts.DEFAULT_SCAN_BATCH_MAX_SIZE_IN_BYTES) - - batches = [documents] if scan_type == consts.SCA_SCAN_TYPE else split_documents_into_batches(documents, max_size) + # batching is disabled for SCA; requested by Mor + batches = [documents] if scan_type == consts.SCA_SCAN_TYPE else split_documents_into_batches(scan_type, documents) progress_bar.set_section_length(ScanProgressBarSection.SCAN, len(batches)) # * 3 # TODO(MarshalX): we should multiply the count of batches in SCAN section because each batch has 3 steps: @@ -61,9 +120,13 @@ def run_parallel_batched_scan( # it's not possible yet because not all scan types moved to polling mechanism # the progress bar could be significant improved (be more dynamic) in the future + threads_count = _get_threads_count() local_scan_results: List['LocalScanResult'] = [] cli_errors: Dict[str, 'CliError'] = {} - with ThreadPool(processes=_get_threads_count()) as pool: + + logger.debug('Running parallel batched scan, %s', {'threads_count': threads_count, 'batches_count': len(batches)}) + + with ThreadPool(processes=threads_count) as pool: for scan_id, err, result in pool.imap(scan_function, batches): if result: local_scan_results.append(result) From 25ad3ec12e8fc002c46d62d81986934308c150e8 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Fri, 7 Mar 2025 10:33:21 +0100 Subject: [PATCH 146/257] CM-45153, CM-45154, CM-45155, CM-45156, CM-45546 - Migrate CLI from Click to Typer (#283) --- .github/workflows/docker-image.yml | 4 +- .github/workflows/pre_release.yml | 6 +- .github/workflows/release.yml | 6 +- .github/workflows/ruff.yml | 4 +- .github/workflows/tests.yml | 4 +- .github/workflows/tests_full.yml | 2 +- CONTRIBUTING.md | 2 +- Dockerfile | 2 +- README.md | 4 +- cycode/cli/__main__.py | 3 + cycode/cli/app.py | 87 ++++++++ cycode/cli/{commands => apps}/__init__.py | 0 cycode/cli/apps/ai_remediation/__init__.py | 6 + .../ai_remediation/ai_remediation_command.py | 32 +++ cycode/cli/apps/ai_remediation/apply_fix.py | 25 +++ .../apps/ai_remediation/print_remediation.py | 15 ++ cycode/cli/apps/auth/__init__.py | 11 ++ cycode/cli/apps/auth/auth_command.py | 28 +++ .../{commands => apps/auth}/auth_common.py | 16 +- .../{commands => apps}/auth/auth_manager.py | 0 cycode/cli/apps/auth/check_command.py | 25 +++ cycode/cli/apps/auth/models.py | 6 + cycode/cli/apps/configure/__init__.py | 8 + .../cli/apps/configure/configure_command.py | 57 ++++++ cycode/cli/apps/configure/consts.py | 19 ++ cycode/cli/apps/configure/messages.py | 37 ++++ cycode/cli/apps/configure/prompts.py | 48 +++++ cycode/cli/apps/ignore/__init__.py | 6 + .../ignore/ignore_command.py | 114 +++++------ cycode/cli/apps/report/__init__.py | 8 + cycode/cli/apps/report/report_command.py | 11 ++ cycode/cli/apps/report/sbom/__init__.py | 12 ++ .../{commands => apps}/report/sbom/common.py | 2 +- .../report/sbom/path}/__init__.py | 0 .../report/sbom/path/path_command.py | 31 +-- .../report/sbom/repository_url}/__init__.py | 0 .../repository_url/repository_url_command.py | 23 +-- cycode/cli/apps/report/sbom/sbom_command.py | 68 +++++++ .../report/sbom/sbom_report_file.py | 0 cycode/cli/apps/scan/__init__.py | 33 ++++ .../{commands => apps}/scan/code_scanner.py | 146 +++++++------- .../scan/commit_history}/__init__.py | 0 .../commit_history/commit_history_command.py | 33 ++++ .../ignore => apps/scan/path}/__init__.py | 0 cycode/cli/apps/scan/path/path_command.py | 25 +++ .../scan/pre_commit}/__init__.py | 0 .../scan/pre_commit/pre_commit_command.py | 24 +-- .../scan/pre_receive}/__init__.py | 0 .../scan/pre_receive/pre_receive_command.py | 27 +-- .../path => apps/scan/repository}/__init__.py | 0 .../scan/repository/repository_command.py | 44 ++--- .../scan/scan_ci}/__init__.py | 0 .../scan/scan_ci/ci_integrations.py | 0 .../cli/apps/scan/scan_ci/scan_ci_command.py | 20 ++ cycode/cli/apps/scan/scan_command.py | 148 ++++++++++++++ cycode/cli/apps/status/__init__.py | 8 + cycode/cli/apps/status/get_cli_status.py | 45 +++++ cycode/cli/apps/status/models.py | 62 ++++++ cycode/cli/apps/status/status_command.py | 15 ++ cycode/cli/apps/status/version_command.py | 15 ++ cycode/cli/cli_types.py | 53 +++++ .../ai_remediation/ai_remediation_command.py | 67 ------- cycode/cli/commands/auth/auth_command.py | 82 -------- .../commands/configure/configure_command.py | 140 ------------- cycode/cli/commands/main_cli.py | 117 ----------- cycode/cli/commands/report/report_command.py | 21 -- .../cli/commands/report/sbom/sbom_command.py | 87 -------- cycode/cli/commands/scan/__init__.py | 0 .../commands/scan/commit_history/__init__.py | 0 .../commit_history/commit_history_command.py | 27 --- cycode/cli/commands/scan/path/__init__.py | 0 cycode/cli/commands/scan/path/path_command.py | 20 -- .../cli/commands/scan/pre_commit/__init__.py | 0 .../cli/commands/scan/pre_receive/__init__.py | 0 .../cli/commands/scan/repository/__init__.py | 0 cycode/cli/commands/scan/scan_ci/__init__.py | 0 .../commands/scan/scan_ci/scan_ci_command.py | 19 -- cycode/cli/commands/scan/scan_command.py | 187 ------------------ cycode/cli/commands/status/__init__.py | 0 cycode/cli/commands/status/status_command.py | 122 ------------ cycode/cli/commands/version/__init__.py | 0 .../cli/commands/version/version_command.py | 22 --- cycode/cli/consts.py | 13 +- .../handle_ai_remediation_errors.py | 8 +- cycode/cli/exceptions/handle_auth_errors.py | 18 ++ .../{common.py => handle_errors.py} | 15 +- .../exceptions/handle_report_sbom_errors.py | 8 +- cycode/cli/exceptions/handle_scan_errors.py | 12 +- cycode/cli/files_collector/excluder.py | 4 +- .../iac/tf_content_generator.py | 2 +- cycode/cli/files_collector/path_documents.py | 4 +- .../sca/base_restore_dependencies.py | 10 +- .../sca/go/restore_go_dependencies.py | 6 +- .../sca/maven/restore_gradle_dependencies.py | 12 +- .../sca/maven/restore_maven_dependencies.py | 6 +- .../sca/npm/restore_npm_dependencies.py | 6 +- .../sca/nuget/restore_nuget_dependencies.py | 6 +- .../files_collector/sca/sca_code_scanner.py | 36 ++-- cycode/cli/main.py | 13 +- cycode/cli/models.py | 29 --- cycode/cli/printers/console_printer.py | 24 +-- cycode/cli/printers/json_printer.py | 2 +- cycode/cli/printers/printer_base.py | 5 +- .../cli/printers/tables/sca_table_printer.py | 12 +- cycode/cli/printers/tables/table_printer.py | 6 +- .../cli/printers/tables/table_printer_base.py | 15 +- cycode/cli/printers/text_printer.py | 13 +- .../cli/user_settings/credentials_manager.py | 2 +- cycode/cli/utils/path_utils.py | 10 +- cycode/cli/utils/scan_batch.py | 77 +++++++- cycode/cli/utils/scan_utils.py | 12 +- cycode/cli/{ => utils}/sentry.py | 0 .../version => utils}/version_checker.py | 13 +- cycode/cyclient/headers.py | 2 +- cycode/cyclient/scan_client.py | 15 +- cycode/cyclient/scan_config_base.py | 6 +- poetry.lock | 125 ++++++++++-- pyproject.toml | 6 +- .../configure/test_configure_command.py | 32 +-- tests/cli/commands/scan/test_code_scanner.py | 6 +- .../test_check_latest_version_on_close.py | 19 +- tests/cli/commands/test_main_command.py | 15 +- .../commands/version/test_version_checker.py | 2 +- .../cli/exceptions/test_handle_scan_errors.py | 18 +- tests/cli/models/test_severity.py | 27 +-- .../scan_config/test_default_scan_config.py | 2 +- .../scan_config/test_dev_scan_config.py | 2 +- tests/cyclient/test_auth_client.py | 2 +- tests/cyclient/test_scan_client.py | 2 +- tests/test_code_scanner.py | 2 +- 130 files changed, 1606 insertions(+), 1417 deletions(-) create mode 100644 cycode/cli/__main__.py create mode 100644 cycode/cli/app.py rename cycode/cli/{commands => apps}/__init__.py (100%) create mode 100644 cycode/cli/apps/ai_remediation/__init__.py create mode 100644 cycode/cli/apps/ai_remediation/ai_remediation_command.py create mode 100644 cycode/cli/apps/ai_remediation/apply_fix.py create mode 100644 cycode/cli/apps/ai_remediation/print_remediation.py create mode 100644 cycode/cli/apps/auth/__init__.py create mode 100644 cycode/cli/apps/auth/auth_command.py rename cycode/cli/{commands => apps/auth}/auth_common.py (75%) rename cycode/cli/{commands => apps}/auth/auth_manager.py (100%) create mode 100644 cycode/cli/apps/auth/check_command.py create mode 100644 cycode/cli/apps/auth/models.py create mode 100644 cycode/cli/apps/configure/__init__.py create mode 100644 cycode/cli/apps/configure/configure_command.py create mode 100644 cycode/cli/apps/configure/consts.py create mode 100644 cycode/cli/apps/configure/messages.py create mode 100644 cycode/cli/apps/configure/prompts.py create mode 100644 cycode/cli/apps/ignore/__init__.py rename cycode/cli/{commands => apps}/ignore/ignore_command.py (60%) create mode 100644 cycode/cli/apps/report/__init__.py create mode 100644 cycode/cli/apps/report/report_command.py create mode 100644 cycode/cli/apps/report/sbom/__init__.py rename cycode/cli/{commands => apps}/report/sbom/common.py (97%) rename cycode/cli/{commands/ai_remediation => apps/report/sbom/path}/__init__.py (100%) rename cycode/cli/{commands => apps}/report/sbom/path/path_command.py (74%) rename cycode/cli/{commands/auth => apps/report/sbom/repository_url}/__init__.py (100%) rename cycode/cli/{commands => apps}/report/sbom/repository_url/repository_url_command.py (73%) create mode 100644 cycode/cli/apps/report/sbom/sbom_command.py rename cycode/cli/{commands => apps}/report/sbom/sbom_report_file.py (100%) create mode 100644 cycode/cli/apps/scan/__init__.py rename cycode/cli/{commands => apps}/scan/code_scanner.py (89%) rename cycode/cli/{commands/configure => apps/scan/commit_history}/__init__.py (100%) create mode 100644 cycode/cli/apps/scan/commit_history/commit_history_command.py rename cycode/cli/{commands/ignore => apps/scan/path}/__init__.py (100%) create mode 100644 cycode/cli/apps/scan/path/path_command.py rename cycode/cli/{commands/report => apps/scan/pre_commit}/__init__.py (100%) rename cycode/cli/{commands => apps}/scan/pre_commit/pre_commit_command.py (64%) rename cycode/cli/{commands/report/sbom => apps/scan/pre_receive}/__init__.py (100%) rename cycode/cli/{commands => apps}/scan/pre_receive/pre_receive_command.py (73%) rename cycode/cli/{commands/report/sbom/path => apps/scan/repository}/__init__.py (100%) rename cycode/cli/{commands => apps}/scan/repository/repository_command.py (66%) rename cycode/cli/{commands/report/sbom/repository_url => apps/scan/scan_ci}/__init__.py (100%) rename cycode/cli/{commands => apps}/scan/scan_ci/ci_integrations.py (100%) create mode 100644 cycode/cli/apps/scan/scan_ci/scan_ci_command.py create mode 100644 cycode/cli/apps/scan/scan_command.py create mode 100644 cycode/cli/apps/status/__init__.py create mode 100644 cycode/cli/apps/status/get_cli_status.py create mode 100644 cycode/cli/apps/status/models.py create mode 100644 cycode/cli/apps/status/status_command.py create mode 100644 cycode/cli/apps/status/version_command.py create mode 100644 cycode/cli/cli_types.py delete mode 100644 cycode/cli/commands/ai_remediation/ai_remediation_command.py delete mode 100644 cycode/cli/commands/auth/auth_command.py delete mode 100644 cycode/cli/commands/configure/configure_command.py delete mode 100644 cycode/cli/commands/main_cli.py delete mode 100644 cycode/cli/commands/report/report_command.py delete mode 100644 cycode/cli/commands/report/sbom/sbom_command.py delete mode 100644 cycode/cli/commands/scan/__init__.py delete mode 100644 cycode/cli/commands/scan/commit_history/__init__.py delete mode 100644 cycode/cli/commands/scan/commit_history/commit_history_command.py delete mode 100644 cycode/cli/commands/scan/path/__init__.py delete mode 100644 cycode/cli/commands/scan/path/path_command.py delete mode 100644 cycode/cli/commands/scan/pre_commit/__init__.py delete mode 100644 cycode/cli/commands/scan/pre_receive/__init__.py delete mode 100644 cycode/cli/commands/scan/repository/__init__.py delete mode 100644 cycode/cli/commands/scan/scan_ci/__init__.py delete mode 100644 cycode/cli/commands/scan/scan_ci/scan_ci_command.py delete mode 100644 cycode/cli/commands/scan/scan_command.py delete mode 100644 cycode/cli/commands/status/__init__.py delete mode 100644 cycode/cli/commands/status/status_command.py delete mode 100644 cycode/cli/commands/version/__init__.py delete mode 100644 cycode/cli/commands/version/version_command.py create mode 100644 cycode/cli/exceptions/handle_auth_errors.py rename cycode/cli/exceptions/{common.py => handle_errors.py} (61%) rename cycode/cli/{ => utils}/sentry.py (100%) rename cycode/cli/{commands/version => utils}/version_checker.py (95%) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 42467e02..4101ded8 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -27,10 +27,10 @@ jobs: run: | git checkout ${{ steps.latest_tag.outputs.LATEST_TAG }} - - name: Set up Python 3.8 + - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.8' + python-version: '3.9' - name: Load cached Poetry setup id: cached_poetry diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 7aca89c1..9b665d73 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -32,10 +32,10 @@ jobs: with: fetch-depth: 0 - - name: Set up Python 3.8 - uses: actions/setup-python@v4 + - name: Set up Python + uses: actions/setup-python@v5 with: - python-version: '3.8' + python-version: '3.9' - name: Load cached Poetry setup id: cached-poetry diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 40913767..aacbae5e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,10 +31,10 @@ jobs: with: fetch-depth: 0 - - name: Set up Python 3.8 - uses: actions/setup-python@v4 + - name: Set up Python + uses: actions/setup-python@v5 with: - python-version: '3.8' + python-version: '3.9' - name: Load cached Poetry setup id: cached-poetry diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 3a91d0f3..575abfd0 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -21,9 +21,9 @@ jobs: uses: actions/checkout@v3 - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: 3.8 + python-version: 3.9 - name: Load cached Poetry setup id: cached-poetry diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0b5ddb58..a62a01b5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,9 +26,9 @@ jobs: uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: '3.8' + python-version: '3.9' - name: Load cached Poetry setup id: cached-poetry diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index 985a3d36..a9ddd4f6 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: os: [ macos-latest, ubuntu-latest, windows-latest ] - python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12", "3.13" ] + python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13" ] runs-on: ${{matrix.os}} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a95c8c28..75b8e85f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ ## How to contribute to Cycode CLI -The minimum version of Python that we support is 3.8. +The minimum version of Python that we support is 3.9. We recommend using this version for local development. But it’s fine to use a higher version without using new features from these versions. diff --git a/Dockerfile b/Dockerfile index 641b829d..8867d1f2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ FROM base AS builder ENV POETRY_VERSION=1.8.3 # deps are required to build cffi -RUN apk add --no-cache --virtual .build-deps gcc=14.2.0-r4 libffi-dev=3.4.6-r0 musl-dev=1.2.5-r9 && \ +RUN apk add --no-cache --virtual .build-deps gcc=14.2.0-r4 libffi-dev=3.4.7-r0 musl-dev=1.2.5-r9 && \ pip install --no-cache-dir "poetry==$POETRY_VERSION" "poetry-dynamic-versioning[plugin]" && \ apk del .build-deps gcc libffi-dev musl-dev diff --git a/README.md b/README.md index 189d69f2..b30bc533 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ This guide walks you through both installation and usage. # Prerequisites -- The Cycode CLI application requires Python version 3.8 or later. +- The Cycode CLI application requires Python version 3.9 or later. - Use the [`cycode auth` command](#using-the-auth-command) to authenticate to Cycode with the CLI - Alternatively, you can get a Cycode Client ID and Client Secret Key by following the steps detailed in the [Service Account Token](https://docs.cycode.com/docs/en/service-accounts) and [Personal Access Token](https://docs.cycode.com/v1/docs/managing-personal-access-tokens) pages, which contain details on getting these values. @@ -208,7 +208,7 @@ Cycode’s pre-commit hook can be set up within your local repository so that th Perform the following steps to install the pre-commit hook: -1. Install the pre-commit framework (Python 3.8 or higher must be installed): +1. Install the pre-commit framework (Python 3.9 or higher must be installed): ```bash pip3 install pre-commit diff --git a/cycode/cli/__main__.py b/cycode/cli/__main__.py new file mode 100644 index 00000000..dad7ac12 --- /dev/null +++ b/cycode/cli/__main__.py @@ -0,0 +1,3 @@ +from cycode.cli.main import app + +app() diff --git a/cycode/cli/app.py b/cycode/cli/app.py new file mode 100644 index 00000000..d3fc10ca --- /dev/null +++ b/cycode/cli/app.py @@ -0,0 +1,87 @@ +import logging +from typing import Annotated, Optional + +import typer + +from cycode import __version__ +from cycode.cli.apps import ai_remediation, auth, configure, ignore, report, scan, status +from cycode.cli.cli_types import OutputTypeOption +from cycode.cli.consts import CLI_CONTEXT_SETTINGS +from cycode.cli.user_settings.configuration_manager import ConfigurationManager +from cycode.cli.utils.progress_bar import SCAN_PROGRESS_BAR_SECTIONS, get_progress_bar +from cycode.cli.utils.sentry import add_breadcrumb, init_sentry +from cycode.cli.utils.version_checker import version_checker +from cycode.cyclient.config import set_logging_level +from cycode.cyclient.cycode_client_base import CycodeClientBase +from cycode.cyclient.models import UserAgentOptionScheme + +app = typer.Typer( + pretty_exceptions_show_locals=False, + pretty_exceptions_short=True, + context_settings=CLI_CONTEXT_SETTINGS, + rich_markup_mode='rich', +) + +app.add_typer(ai_remediation.app) +app.add_typer(auth.app) +app.add_typer(configure.app) +app.add_typer(ignore.app) +app.add_typer(report.app) +app.add_typer(scan.app) +app.add_typer(status.app) + + +def check_latest_version_on_close(ctx: typer.Context) -> None: + output = ctx.obj.get('output') + # don't print anything if the output is JSON + if output == OutputTypeOption.JSON: + return + + # we always want to check the latest version for "version" and "status" commands + should_use_cache = ctx.invoked_subcommand not in {'version', 'status'} + version_checker.check_and_notify_update( + current_version=__version__, use_color=ctx.color, use_cache=should_use_cache + ) + + +@app.callback() +def app_callback( + ctx: typer.Context, + verbose: Annotated[bool, typer.Option('--verbose', '-v', help='Show detailed logs.')] = False, + no_progress_meter: Annotated[ + bool, typer.Option('--no-progress-meter', help='Do not show the progress meter.') + ] = False, + no_update_notifier: Annotated[ + bool, typer.Option('--no-update-notifier', help='Do not check CLI for updates.') + ] = False, + output: Annotated[ + OutputTypeOption, typer.Option('--output', '-o', case_sensitive=False, help='Specify the output type.') + ] = OutputTypeOption.TEXT, + user_agent: Annotated[ + Optional[str], + typer.Option(hidden=True, help='Characteristic JSON object that lets servers identify the application.'), + ] = None, +) -> None: + init_sentry() + add_breadcrumb('cycode') + + ctx.ensure_object(dict) + configuration_manager = ConfigurationManager() + + verbose = verbose or configuration_manager.get_verbose_flag() + ctx.obj['verbose'] = verbose + if verbose: + set_logging_level(logging.DEBUG) + + ctx.obj['output'] = output + if output == OutputTypeOption.JSON: + no_progress_meter = True + + ctx.obj['progress_bar'] = get_progress_bar(hidden=no_progress_meter, sections=SCAN_PROGRESS_BAR_SECTIONS) + + if user_agent: + user_agent_option = UserAgentOptionScheme().loads(user_agent) + CycodeClientBase.enrich_user_agent(user_agent_option.user_agent_suffix) + + if not no_update_notifier: + ctx.call_on_close(lambda: check_latest_version_on_close(ctx)) diff --git a/cycode/cli/commands/__init__.py b/cycode/cli/apps/__init__.py similarity index 100% rename from cycode/cli/commands/__init__.py rename to cycode/cli/apps/__init__.py diff --git a/cycode/cli/apps/ai_remediation/__init__.py b/cycode/cli/apps/ai_remediation/__init__.py new file mode 100644 index 00000000..2ccba382 --- /dev/null +++ b/cycode/cli/apps/ai_remediation/__init__.py @@ -0,0 +1,6 @@ +import typer + +from cycode.cli.apps.ai_remediation.ai_remediation_command import ai_remediation_command + +app = typer.Typer() +app.command(name='ai_remediation', short_help='Get AI remediation (INTERNAL).', hidden=True)(ai_remediation_command) diff --git a/cycode/cli/apps/ai_remediation/ai_remediation_command.py b/cycode/cli/apps/ai_remediation/ai_remediation_command.py new file mode 100644 index 00000000..0a82b815 --- /dev/null +++ b/cycode/cli/apps/ai_remediation/ai_remediation_command.py @@ -0,0 +1,32 @@ +from typing import Annotated +from uuid import UUID + +import typer + +from cycode.cli.apps.ai_remediation.apply_fix import apply_fix +from cycode.cli.apps.ai_remediation.print_remediation import print_remediation +from cycode.cli.exceptions.handle_ai_remediation_errors import handle_ai_remediation_exception +from cycode.cli.utils.get_api_client import get_scan_cycode_client + + +def ai_remediation_command( + ctx: typer.Context, + detection_id: Annotated[UUID, typer.Argument(help='Detection ID to get remediation for', show_default=False)], + fix: Annotated[ + bool, typer.Option('--fix', help='Apply fixes to resolve violations. Note: fix could be not available.') + ] = False, +) -> None: + """Get AI remediation (INTERNAL).""" + client = get_scan_cycode_client() + + try: + remediation_markdown = client.get_ai_remediation(detection_id) + fix_diff = client.get_ai_remediation(detection_id, fix=True) + is_fix_available = bool(fix_diff) # exclude empty string, None, etc. + + if fix: + apply_fix(ctx, fix_diff, is_fix_available) + else: + print_remediation(ctx, remediation_markdown, is_fix_available) + except Exception as err: + handle_ai_remediation_exception(ctx, err) diff --git a/cycode/cli/apps/ai_remediation/apply_fix.py b/cycode/cli/apps/ai_remediation/apply_fix.py new file mode 100644 index 00000000..e0c2599b --- /dev/null +++ b/cycode/cli/apps/ai_remediation/apply_fix.py @@ -0,0 +1,25 @@ +import os + +import typer +from patch_ng import fromstring + +from cycode.cli.models import CliResult +from cycode.cli.printers import ConsolePrinter + + +def apply_fix(ctx: typer.Context, diff: str, is_fix_available: bool) -> None: + printer = ConsolePrinter(ctx) + if not is_fix_available: + printer.print_result(CliResult(success=False, message='Fix is not available for this violation')) + return + + patch = fromstring(diff.encode('UTF-8')) + if patch is False: + printer.print_result(CliResult(success=False, message='Failed to parse fix diff')) + return + + is_fix_applied = patch.apply(root=os.getcwd(), strip=0) + if is_fix_applied: + printer.print_result(CliResult(success=True, message='Fix applied successfully')) + else: + printer.print_result(CliResult(success=False, message='Failed to apply fix')) diff --git a/cycode/cli/apps/ai_remediation/print_remediation.py b/cycode/cli/apps/ai_remediation/print_remediation.py new file mode 100644 index 00000000..c706c13f --- /dev/null +++ b/cycode/cli/apps/ai_remediation/print_remediation.py @@ -0,0 +1,15 @@ +import typer +from rich.console import Console +from rich.markdown import Markdown + +from cycode.cli.models import CliResult +from cycode.cli.printers import ConsolePrinter + + +def print_remediation(ctx: typer.Context, remediation_markdown: str, is_fix_available: bool) -> None: + printer = ConsolePrinter(ctx) + if printer.is_json_printer: + data = {'remediation': remediation_markdown, 'is_fix_available': is_fix_available} + printer.print_result(CliResult(success=True, message='Remediation fetched successfully', data=data)) + else: # text or table + Console().print(Markdown(remediation_markdown)) diff --git a/cycode/cli/apps/auth/__init__.py b/cycode/cli/apps/auth/__init__.py new file mode 100644 index 00000000..82e71fbc --- /dev/null +++ b/cycode/cli/apps/auth/__init__.py @@ -0,0 +1,11 @@ +import typer + +from cycode.cli.apps.auth.auth_command import auth_command +from cycode.cli.apps.auth.check_command import check_command + +app = typer.Typer( + name='auth', + help='Authenticate your machine to associate the CLI with your Cycode account.', +) +app.callback(invoke_without_command=True)(auth_command) +app.command(name='check')(check_command) diff --git a/cycode/cli/apps/auth/auth_command.py b/cycode/cli/apps/auth/auth_command.py new file mode 100644 index 00000000..c0b2fb89 --- /dev/null +++ b/cycode/cli/apps/auth/auth_command.py @@ -0,0 +1,28 @@ +import typer + +from cycode.cli.apps.auth.auth_manager import AuthManager +from cycode.cli.exceptions.handle_auth_errors import handle_auth_exception +from cycode.cli.models import CliResult +from cycode.cli.printers import ConsolePrinter +from cycode.cli.utils.sentry import add_breadcrumb +from cycode.cyclient import logger + + +def auth_command(ctx: typer.Context) -> None: + """Authenticates your machine.""" + add_breadcrumb('auth') + + if ctx.invoked_subcommand is not None: + # if it is a subcommand, do nothing + return + + try: + logger.debug('Starting authentication process') + + auth_manager = AuthManager() + auth_manager.authenticate() + + result = CliResult(success=True, message='Successfully logged into cycode') + ConsolePrinter(ctx).print_result(result) + except Exception as err: + handle_auth_exception(ctx, err) diff --git a/cycode/cli/commands/auth_common.py b/cycode/cli/apps/auth/auth_common.py similarity index 75% rename from cycode/cli/commands/auth_common.py rename to cycode/cli/apps/auth/auth_common.py index bf8d5d41..fffee388 100644 --- a/cycode/cli/commands/auth_common.py +++ b/cycode/cli/apps/auth/auth_common.py @@ -1,7 +1,8 @@ -from typing import NamedTuple, Optional +from typing import Optional -import click +import typer +from cycode.cli.apps.auth.models import AuthInfo from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError, RequestHttpError from cycode.cli.printers import ConsolePrinter from cycode.cli.user_settings.credentials_manager import CredentialsManager @@ -9,12 +10,7 @@ from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient -class AuthInfo(NamedTuple): - user_id: str - tenant_id: str - - -def get_authorization_info(context: Optional[click.Context] = None) -> Optional[AuthInfo]: +def get_authorization_info(ctx: Optional[typer.Context] = None) -> Optional[AuthInfo]: client_id, client_secret = CredentialsManager().get_credentials() if not client_id or not client_secret: return None @@ -27,7 +23,7 @@ def get_authorization_info(context: Optional[click.Context] = None) -> Optional[ user_id, tenant_id = get_user_and_tenant_ids_from_access_token(access_token) return AuthInfo(user_id=user_id, tenant_id=tenant_id) except (RequestHttpError, HttpUnauthorizedError): - if context: - ConsolePrinter(context).print_exception() + if ctx: + ConsolePrinter(ctx).print_exception() return None diff --git a/cycode/cli/commands/auth/auth_manager.py b/cycode/cli/apps/auth/auth_manager.py similarity index 100% rename from cycode/cli/commands/auth/auth_manager.py rename to cycode/cli/apps/auth/auth_manager.py diff --git a/cycode/cli/apps/auth/check_command.py b/cycode/cli/apps/auth/check_command.py new file mode 100644 index 00000000..cfa57f1c --- /dev/null +++ b/cycode/cli/apps/auth/check_command.py @@ -0,0 +1,25 @@ +import typer + +from cycode.cli.apps.auth.auth_common import get_authorization_info +from cycode.cli.models import CliResult +from cycode.cli.printers import ConsolePrinter +from cycode.cli.utils.sentry import add_breadcrumb + + +def check_command(ctx: typer.Context) -> None: + """Checks that your machine is associating the CLI with your Cycode account.""" + add_breadcrumb('check') + + printer = ConsolePrinter(ctx) + auth_info = get_authorization_info(ctx) + if auth_info is None: + printer.print_result(CliResult(success=False, message='Cycode authentication failed')) + return + + printer.print_result( + CliResult( + success=True, + message='Cycode authentication verified', + data={'user_id': auth_info.user_id, 'tenant_id': auth_info.tenant_id}, + ) + ) diff --git a/cycode/cli/apps/auth/models.py b/cycode/cli/apps/auth/models.py new file mode 100644 index 00000000..4b41dd3e --- /dev/null +++ b/cycode/cli/apps/auth/models.py @@ -0,0 +1,6 @@ +from typing import NamedTuple + + +class AuthInfo(NamedTuple): + user_id: str + tenant_id: str diff --git a/cycode/cli/apps/configure/__init__.py b/cycode/cli/apps/configure/__init__.py new file mode 100644 index 00000000..815874d1 --- /dev/null +++ b/cycode/cli/apps/configure/__init__.py @@ -0,0 +1,8 @@ +import typer + +from cycode.cli.apps.configure.configure_command import configure_command + +app = typer.Typer() +app.command(name='configure', short_help='Initial command to configure your CLI client authentication.')( + configure_command +) diff --git a/cycode/cli/apps/configure/configure_command.py b/cycode/cli/apps/configure/configure_command.py new file mode 100644 index 00000000..9c631641 --- /dev/null +++ b/cycode/cli/apps/configure/configure_command.py @@ -0,0 +1,57 @@ +from typing import Optional + +import typer + +from cycode.cli.apps.configure.consts import CONFIGURATION_MANAGER, CREDENTIALS_MANAGER +from cycode.cli.apps.configure.messages import get_credentials_update_result_message, get_urls_update_result_message +from cycode.cli.apps.configure.prompts import ( + get_api_url_input, + get_app_url_input, + get_client_id_input, + get_client_secret_input, +) +from cycode.cli.utils.sentry import add_breadcrumb + + +def _should_update_value( + old_value: Optional[str], + new_value: Optional[str], +) -> bool: + if not new_value: + return False + + return old_value != new_value + + +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() + current_app_url = global_config_manager.get_app_url() + api_url = get_api_url_input(current_api_url) + app_url = get_app_url_input(current_app_url) + + config_updated = False + if _should_update_value(current_api_url, api_url): + global_config_manager.update_api_base_url(api_url) + config_updated = True + if _should_update_value(current_app_url, app_url): + global_config_manager.update_app_base_url(app_url) + config_updated = True + + current_client_id, current_client_secret = CREDENTIALS_MANAGER.get_credentials_from_file() + client_id = get_client_id_input(current_client_id) + client_secret = get_client_secret_input(current_client_secret) + + credentials_updated = False + if _should_update_value(current_client_id, client_id) or _should_update_value(current_client_secret, client_secret): + credentials_updated = True + CREDENTIALS_MANAGER.update_credentials(client_id, client_secret) + + if config_updated: + typer.echo(get_urls_update_result_message()) + if credentials_updated: + typer.echo(get_credentials_update_result_message()) diff --git a/cycode/cli/apps/configure/consts.py b/cycode/cli/apps/configure/consts.py new file mode 100644 index 00000000..15c9b7a5 --- /dev/null +++ b/cycode/cli/apps/configure/consts.py @@ -0,0 +1,19 @@ +from cycode.cli import config, consts +from cycode.cli.user_settings.configuration_manager import ConfigurationManager +from cycode.cli.user_settings.credentials_manager import CredentialsManager + +URLS_UPDATED_SUCCESSFULLY_MESSAGE = 'Successfully configured Cycode URLs! Saved to: {filename}' +URLS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE = ( + 'Note that the URLs (APP and API) that already exist in environment variables ' + f'({consts.CYCODE_API_URL_ENV_VAR_NAME} and {consts.CYCODE_APP_URL_ENV_VAR_NAME}) ' + 'take precedent over these URLs; either update or remove the environment variables.' +) +CREDENTIALS_UPDATED_SUCCESSFULLY_MESSAGE = 'Successfully configured CLI credentials! Saved to: {filename}' +CREDENTIALS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE = ( + 'Note that the credentials that already exist in environment variables ' + f'({config.CYCODE_CLIENT_ID_ENV_VAR_NAME} and {config.CYCODE_CLIENT_SECRET_ENV_VAR_NAME}) ' + 'take precedent over these credentials; either update or remove the environment variables.' +) + +CREDENTIALS_MANAGER = CredentialsManager() +CONFIGURATION_MANAGER = ConfigurationManager() diff --git a/cycode/cli/apps/configure/messages.py b/cycode/cli/apps/configure/messages.py new file mode 100644 index 00000000..36ce807b --- /dev/null +++ b/cycode/cli/apps/configure/messages.py @@ -0,0 +1,37 @@ +from cycode.cli.apps.configure.consts import ( + CONFIGURATION_MANAGER, + CREDENTIALS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE, + CREDENTIALS_MANAGER, + CREDENTIALS_UPDATED_SUCCESSFULLY_MESSAGE, + URLS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE, + URLS_UPDATED_SUCCESSFULLY_MESSAGE, +) + + +def _are_credentials_exist_in_environment_variables() -> bool: + client_id, client_secret = CREDENTIALS_MANAGER.get_credentials_from_environment_variables() + return any([client_id, client_secret]) + + +def get_credentials_update_result_message() -> str: + success_message = CREDENTIALS_UPDATED_SUCCESSFULLY_MESSAGE.format(filename=CREDENTIALS_MANAGER.get_filename()) + if _are_credentials_exist_in_environment_variables(): + return f'{success_message}. {CREDENTIALS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE}' + + return success_message + + +def _are_urls_exist_in_environment_variables() -> bool: + api_url = CONFIGURATION_MANAGER.get_api_url_from_environment_variables() + app_url = CONFIGURATION_MANAGER.get_app_url_from_environment_variables() + return any([api_url, app_url]) + + +def get_urls_update_result_message() -> str: + success_message = URLS_UPDATED_SUCCESSFULLY_MESSAGE.format( + filename=CONFIGURATION_MANAGER.global_config_file_manager.get_filename() + ) + if _are_urls_exist_in_environment_variables(): + return f'{success_message}. {URLS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE}' + + return success_message diff --git a/cycode/cli/apps/configure/prompts.py b/cycode/cli/apps/configure/prompts.py new file mode 100644 index 00000000..3025688d --- /dev/null +++ b/cycode/cli/apps/configure/prompts.py @@ -0,0 +1,48 @@ +from typing import Optional + +import typer + +from cycode.cli import consts +from cycode.cli.utils.string_utils import obfuscate_text + + +def get_client_id_input(current_client_id: Optional[str]) -> Optional[str]: + prompt_text = 'Cycode Client ID' + + prompt_suffix = ' []: ' + if current_client_id: + prompt_suffix = f' [{obfuscate_text(current_client_id)}]: ' + + new_client_id = typer.prompt(text=prompt_text, prompt_suffix=prompt_suffix, default='', show_default=False) + return new_client_id or current_client_id + + +def get_client_secret_input(current_client_secret: Optional[str]) -> Optional[str]: + prompt_text = 'Cycode Client Secret' + + prompt_suffix = ' []: ' + if current_client_secret: + prompt_suffix = f' [{obfuscate_text(current_client_secret)}]: ' + + new_client_secret = typer.prompt(text=prompt_text, prompt_suffix=prompt_suffix, default='', show_default=False) + return new_client_secret or current_client_secret + + +def get_app_url_input(current_app_url: Optional[str]) -> str: + prompt_text = 'Cycode APP URL' + + default = consts.DEFAULT_CYCODE_APP_URL + if current_app_url: + default = current_app_url + + return typer.prompt(text=prompt_text, default=default, type=str) + + +def get_api_url_input(current_api_url: Optional[str]) -> str: + prompt_text = 'Cycode API URL' + + default = consts.DEFAULT_CYCODE_API_URL + if current_api_url: + default = current_api_url + + return typer.prompt(text=prompt_text, default=default, type=str) diff --git a/cycode/cli/apps/ignore/__init__.py b/cycode/cli/apps/ignore/__init__.py new file mode 100644 index 00000000..3c51d38a --- /dev/null +++ b/cycode/cli/apps/ignore/__init__.py @@ -0,0 +1,6 @@ +import typer + +from cycode.cli.apps.ignore.ignore_command import ignore_command + +app = typer.Typer() +app.command(name='ignore', short_help='Ignores a specific value, path or rule ID.')(ignore_command) diff --git a/cycode/cli/commands/ignore/ignore_command.py b/cycode/cli/apps/ignore/ignore_command.py similarity index 60% rename from cycode/cli/commands/ignore/ignore_command.py rename to cycode/cli/apps/ignore/ignore_command.py index b94c5612..3ac3ffff 100644 --- a/cycode/cli/commands/ignore/ignore_command.py +++ b/cycode/cli/apps/ignore/ignore_command.py @@ -1,12 +1,14 @@ import re -from typing import Optional +from typing import Annotated, Optional import click +import typer from cycode.cli import consts -from cycode.cli.config import config, configuration_manager -from cycode.cli.sentry import add_breadcrumb +from cycode.cli.cli_types import ScanTypeOption +from cycode.cli.config import configuration_manager from cycode.cli.utils.path_utils import get_absolute_path, is_path_exists +from cycode.cli.utils.sentry import add_breadcrumb from cycode.cli.utils.string_utils import hash_string_to_sha256 from cycode.cyclient import logger @@ -15,66 +17,54 @@ def _is_package_pattern_valid(package: str) -> bool: return re.search('^[^@]+@[^@]+$', package) is not None -@click.command(short_help='Ignores a specific value, path or rule ID.') -@click.option( - '--by-value', type=click.STRING, required=False, help='Ignore a specific value while scanning for Secrets.' -) -@click.option( - '--by-sha', - type=click.STRING, - required=False, - help='Ignore a specific SHA512 representation of a string while scanning for Secrets.', -) -@click.option( - '--by-path', - type=click.STRING, - required=False, - help='Avoid scanning a specific path. You`ll need to specify the scan type.', -) -@click.option( - '--by-rule', - type=click.STRING, - required=False, - help='Ignore scanning a specific secret rule ID or IaC rule ID. You`ll to specify the scan type.', -) -@click.option( - '--by-package', - type=click.STRING, - required=False, - help='Ignore scanning a specific package version while running an SCA scan. Expected pattern: name@version.', -) -@click.option( - '--by-cve', - type=click.STRING, - required=False, - help='Ignore scanning a specific CVE while running an SCA scan. Expected pattern: CVE-YYYY-NNN.', -) -@click.option( - '--scan-type', - '-t', - default=consts.SECRET_SCAN_TYPE, - help='Specify the type of scan you wish to execute (the default is Secrets).', - type=click.Choice(config['scans']['supported_scans']), - required=False, -) -@click.option( - '--global', - '-g', - 'is_global', - is_flag=True, - default=False, - required=False, - help='Add an ignore rule to the global CLI config.', -) def ignore_command( # noqa: C901 - by_value: Optional[str], - by_sha: Optional[str], - by_path: Optional[str], - by_rule: Optional[str], - by_package: Optional[str], - by_cve: Optional[str], - scan_type: str = consts.SECRET_SCAN_TYPE, - is_global: bool = False, + by_value: Annotated[ + Optional[str], typer.Option(help='Ignore a specific value while scanning for Secrets.', show_default=False) + ] = None, + by_sha: Annotated[ + Optional[str], + typer.Option( + help='Ignore a specific SHA512 representation of a string while scanning for Secrets.', show_default=False + ), + ] = None, + by_path: Annotated[ + Optional[str], + typer.Option(help='Avoid scanning a specific path. You`ll need to specify the scan type.', show_default=False), + ] = None, + by_rule: Annotated[ + Optional[str], + typer.Option( + help='Ignore scanning a specific secret rule ID or IaC rule ID. You`ll to specify the scan type.', + show_default=False, + ), + ] = None, + by_package: Annotated[ + Optional[str], + typer.Option( + help='Ignore scanning a specific package version while running an SCA scan. ' + 'Expected pattern: name@version.', + show_default=False, + ), + ] = None, + by_cve: Annotated[ + Optional[str], + typer.Option( + help='Ignore scanning a specific CVE while running an SCA scan. Expected pattern: CVE-YYYY-NNN.', + show_default=False, + ), + ] = None, + scan_type: Annotated[ + ScanTypeOption, + typer.Option( + '--scan-type', + '-t', + help='Specify the type of scan you wish to execute.', + case_sensitive=False, + ), + ] = ScanTypeOption.SECRET, + is_global: Annotated[ + bool, typer.Option('--global', '-g', help='Add an ignore rule to the global CLI config.') + ] = False, ) -> None: """Ignores a specific value, path or rule ID.""" add_breadcrumb('ignore') diff --git a/cycode/cli/apps/report/__init__.py b/cycode/cli/apps/report/__init__.py new file mode 100644 index 00000000..f71532c8 --- /dev/null +++ b/cycode/cli/apps/report/__init__.py @@ -0,0 +1,8 @@ +import typer + +from cycode.cli.apps.report import sbom +from cycode.cli.apps.report.report_command import report_command + +app = typer.Typer(name='report') +app.callback(short_help='Generate report. You`ll need to specify which report type to perform.')(report_command) +app.add_typer(sbom.app) diff --git a/cycode/cli/apps/report/report_command.py b/cycode/cli/apps/report/report_command.py new file mode 100644 index 00000000..91a061c3 --- /dev/null +++ b/cycode/cli/apps/report/report_command.py @@ -0,0 +1,11 @@ +import typer + +from cycode.cli.utils.progress_bar import SBOM_REPORT_PROGRESS_BAR_SECTIONS, get_progress_bar +from cycode.cli.utils.sentry import add_breadcrumb + + +def report_command(ctx: typer.Context) -> int: + """Generate report.""" + add_breadcrumb('report') + ctx.obj['progress_bar'] = get_progress_bar(hidden=False, sections=SBOM_REPORT_PROGRESS_BAR_SECTIONS) + return 1 diff --git a/cycode/cli/apps/report/sbom/__init__.py b/cycode/cli/apps/report/sbom/__init__.py new file mode 100644 index 00000000..461b3fe0 --- /dev/null +++ b/cycode/cli/apps/report/sbom/__init__.py @@ -0,0 +1,12 @@ +import typer + +from cycode.cli.apps.report.sbom.path.path_command import path_command +from cycode.cli.apps.report.sbom.repository_url.repository_url_command import repository_url_command +from cycode.cli.apps.report.sbom.sbom_command import sbom_command + +app = typer.Typer(name='sbom') +app.callback(short_help='Generate SBOM report for remote repository by url or local directory by path.')(sbom_command) +app.command(name='path', short_help='Generate SBOM report for provided path in the command.')(path_command) +app.command(name='repository_url', short_help='Generate SBOM report for provided repository URI in the command.')( + repository_url_command +) diff --git a/cycode/cli/commands/report/sbom/common.py b/cycode/cli/apps/report/sbom/common.py similarity index 97% rename from cycode/cli/commands/report/sbom/common.py rename to cycode/cli/apps/report/sbom/common.py index 6ea843f5..b296e525 100644 --- a/cycode/cli/commands/report/sbom/common.py +++ b/cycode/cli/apps/report/sbom/common.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Optional from cycode.cli import consts -from cycode.cli.commands.report.sbom.sbom_report_file import SbomReportFile +from cycode.cli.apps.report.sbom.sbom_report_file import SbomReportFile from cycode.cli.config import configuration_manager from cycode.cli.exceptions.custom_exceptions import ReportAsyncError from cycode.cli.utils.progress_bar import SbomReportProgressBarSection diff --git a/cycode/cli/commands/ai_remediation/__init__.py b/cycode/cli/apps/report/sbom/path/__init__.py similarity index 100% rename from cycode/cli/commands/ai_remediation/__init__.py rename to cycode/cli/apps/report/sbom/path/__init__.py diff --git a/cycode/cli/commands/report/sbom/path/path_command.py b/cycode/cli/apps/report/sbom/path/path_command.py similarity index 74% rename from cycode/cli/commands/report/sbom/path/path_command.py rename to cycode/cli/apps/report/sbom/path/path_command.py index c52bc611..20e82848 100644 --- a/cycode/cli/commands/report/sbom/path/path_command.py +++ b/cycode/cli/apps/report/sbom/path/path_command.py @@ -1,30 +1,35 @@ import time +from pathlib import Path +from typing import Annotated -import click +import typer from cycode.cli import consts -from cycode.cli.commands.report.sbom.common import create_sbom_report, send_report_feedback +from cycode.cli.apps.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.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 +from cycode.cli.utils.sentry import add_breadcrumb -@click.command(short_help='Generate SBOM report for provided path in the command.') -@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: +def path_command( + ctx: typer.Context, + path: Annotated[ + Path, + typer.Argument(exists=True, resolve_path=True, help='Path to generate SBOM report for.', show_default=False), + ], +) -> None: add_breadcrumb('path') client = get_report_cycode_client() - report_parameters = context.obj['report_parameters'] + report_parameters = ctx.obj['report_parameters'] output_format = report_parameters.output_format - output_file = context.obj['output_file'] + output_file = ctx.obj['output_file'] - progress_bar = context.obj['progress_bar'] + progress_bar = ctx.obj['progress_bar'] progress_bar.start() start_scan_time = time.time() @@ -32,11 +37,11 @@ def path_command(context: click.Context, path: str) -> None: try: documents = get_relevant_documents( - progress_bar, SbomReportProgressBarSection.PREPARE_LOCAL_FILES, consts.SCA_SCAN_TYPE, (path,) + progress_bar, SbomReportProgressBarSection.PREPARE_LOCAL_FILES, consts.SCA_SCAN_TYPE, (str(path),) ) # TODO(MarshalX): combine perform_pre_scan_documents_actions with get_relevant_document. # unhardcode usage of context in perform_pre_scan_documents_actions - perform_pre_scan_documents_actions(context, consts.SCA_SCAN_TYPE, documents) + perform_pre_scan_documents_actions(ctx, consts.SCA_SCAN_TYPE, documents) zipped_documents = zip_documents(consts.SCA_SCAN_TYPE, documents) report_execution = client.request_sbom_report_execution(report_parameters, zip_file=zipped_documents) @@ -66,4 +71,4 @@ def path_command(context: click.Context, path: str) -> None: error_message=str(e), ) - handle_report_exception(context, e) + handle_report_exception(ctx, e) diff --git a/cycode/cli/commands/auth/__init__.py b/cycode/cli/apps/report/sbom/repository_url/__init__.py similarity index 100% rename from cycode/cli/commands/auth/__init__.py rename to cycode/cli/apps/report/sbom/repository_url/__init__.py diff --git a/cycode/cli/commands/report/sbom/repository_url/repository_url_command.py b/cycode/cli/apps/report/sbom/repository_url/repository_url_command.py similarity index 73% rename from cycode/cli/commands/report/sbom/repository_url/repository_url_command.py rename to cycode/cli/apps/report/sbom/repository_url/repository_url_command.py index 189fd961..28be0114 100644 --- a/cycode/cli/commands/report/sbom/repository_url/repository_url_command.py +++ b/cycode/cli/apps/report/sbom/repository_url/repository_url_command.py @@ -1,27 +1,28 @@ import time +from typing import Annotated -import click +import typer -from cycode.cli.commands.report.sbom.common import create_sbom_report, send_report_feedback +from cycode.cli.apps.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 +from cycode.cli.utils.sentry import add_breadcrumb -@click.command(short_help='Generate SBOM report for provided repository URI in the command.') -@click.argument('uri', nargs=1, type=str, required=True) -@click.pass_context -def repository_url_command(context: click.Context, uri: str) -> None: +def repository_url_command( + ctx: typer.Context, + uri: Annotated[str, typer.Argument(help='Repository URL to generate SBOM report for.', show_default=False)], +) -> None: add_breadcrumb('repository_url') - progress_bar = context.obj['progress_bar'] + progress_bar = ctx.obj['progress_bar'] progress_bar.start() progress_bar.set_section_length(SbomReportProgressBarSection.PREPARE_LOCAL_FILES) client = get_report_cycode_client() - report_parameters = context.obj['report_parameters'] - output_file = context.obj['output_file'] + report_parameters = ctx.obj['report_parameters'] + output_file = ctx.obj['output_file'] output_format = report_parameters.output_format start_scan_time = time.time() @@ -56,4 +57,4 @@ def repository_url_command(context: click.Context, uri: str) -> None: repository_uri=uri, ) - handle_report_exception(context, e) + handle_report_exception(ctx, e) diff --git a/cycode/cli/apps/report/sbom/sbom_command.py b/cycode/cli/apps/report/sbom/sbom_command.py new file mode 100644 index 00000000..65dc3fd9 --- /dev/null +++ b/cycode/cli/apps/report/sbom/sbom_command.py @@ -0,0 +1,68 @@ +from pathlib import Path +from typing import Annotated, Optional + +import click +import typer + +from cycode.cli.cli_types import SbomFormatOption, SbomOutputFormatOption +from cycode.cli.utils.sentry import add_breadcrumb +from cycode.cyclient.report_client import ReportParameters + + +def sbom_command( + ctx: typer.Context, + sbom_format: Annotated[ + SbomFormatOption, + typer.Option( + '--format', + '-f', + help='SBOM format.', + case_sensitive=False, + show_default=False, + ), + ], + output_format: Annotated[ + SbomOutputFormatOption, + typer.Option( + '--output-format', + '-o', + help='Specify the output file format.', + ), + ] = SbomOutputFormatOption.JSON, + output_file: Annotated[ + Optional[Path], + typer.Option( + help='Output file.', + show_default='Autogenerated filename saved to the current directory', + dir_okay=False, + writable=True, + ), + ] = None, + include_vulnerabilities: Annotated[ + bool, typer.Option('--include-vulnerabilities', help='Include vulnerabilities.', show_default=False) + ] = False, + include_dev_dependencies: Annotated[ + bool, typer.Option('--include-dev-dependencies', help='Include dev dependencies.', show_default=False) + ] = False, +) -> int: + """Generate SBOM report.""" + add_breadcrumb('sbom') + + sbom_format_parts = sbom_format.split('-') + if len(sbom_format_parts) != 2: + raise click.ClickException('Invalid SBOM format.') + + sbom_format, sbom_format_version = sbom_format_parts + + report_parameters = ReportParameters( + entity_type='SbomCli', + sbom_report_type=sbom_format, + sbom_version=sbom_format_version, + output_format=output_format, + include_vulnerabilities=include_vulnerabilities, + include_dev_dependencies=include_dev_dependencies, + ) + ctx.obj['report_parameters'] = report_parameters + ctx.obj['output_file'] = output_file + + return 1 diff --git a/cycode/cli/commands/report/sbom/sbom_report_file.py b/cycode/cli/apps/report/sbom/sbom_report_file.py similarity index 100% rename from cycode/cli/commands/report/sbom/sbom_report_file.py rename to cycode/cli/apps/report/sbom/sbom_report_file.py diff --git a/cycode/cli/apps/scan/__init__.py b/cycode/cli/apps/scan/__init__.py new file mode 100644 index 00000000..e8602091 --- /dev/null +++ b/cycode/cli/apps/scan/__init__.py @@ -0,0 +1,33 @@ +import typer + +from cycode.cli.apps.scan.commit_history.commit_history_command import commit_history_command +from cycode.cli.apps.scan.path.path_command import path_command +from cycode.cli.apps.scan.pre_commit.pre_commit_command import pre_commit_command +from cycode.cli.apps.scan.pre_receive.pre_receive_command import pre_receive_command +from cycode.cli.apps.scan.repository.repository_command import repository_command +from cycode.cli.apps.scan.scan_command import scan_command, scan_command_result_callback + +app = typer.Typer(name='scan') + +app.callback( + short_help='Scan the content for Secrets, IaC, SCA, and SAST violations.', + result_callback=scan_command_result_callback, +)(scan_command) + +app.command(name='path', short_help='Scan the files in the paths provided in the command.')(path_command) +app.command(name='repository', short_help='Scan the Git repository included files.')(repository_command) +app.command(name='commit_history', short_help='Scan all the commits history in this git repository.')( + commit_history_command +) + +app.command( + name='pre_commit', + short_help='Use this command in pre-commit hook to scan any content that was not committed yet.', + rich_help_panel='Automation commands', +)(pre_commit_command) +app.command( + name='pre_receive', + short_help='Use this command in pre-receive hook ' + 'to scan commits on the server side before pushing them to the repository.', + rich_help_panel='Automation commands', +)(pre_receive_command) diff --git a/cycode/cli/commands/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py similarity index 89% rename from cycode/cli/commands/scan/code_scanner.py rename to cycode/cli/apps/scan/code_scanner.py index b3fddf59..535507d7 100644 --- a/cycode/cli/commands/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -7,8 +7,10 @@ from uuid import UUID, uuid4 import click +import typer from cycode.cli import consts +from cycode.cli.cli_types import SeverityOption from cycode.cli.config import configuration_manager from cycode.cli.exceptions import custom_exceptions from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception @@ -24,7 +26,7 @@ from cycode.cli.files_collector.sca import sca_code_scanner 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.models import CliError, Document, DocumentDetections, LocalScanResult, Severity +from cycode.cli.models import CliError, Document, DocumentDetections, LocalScanResult from cycode.cli.printers import ConsolePrinter from cycode.cli.utils import scan_utils from cycode.cli.utils.git_proxy import git_proxy @@ -43,17 +45,17 @@ start_scan_time = time.time() -def scan_sca_pre_commit(context: click.Context) -> None: - scan_type = context.obj['scan_type'] - scan_parameters = get_default_scan_parameters(context) +def scan_sca_pre_commit(ctx: typer.Context) -> None: + scan_type = ctx.obj['scan_type'] + scan_parameters = get_default_scan_parameters(ctx) git_head_documents, pre_committed_documents = get_pre_commit_modified_documents( - context.obj['progress_bar'], ScanProgressBarSection.PREPARE_LOCAL_FILES + ctx.obj['progress_bar'], ScanProgressBarSection.PREPARE_LOCAL_FILES ) git_head_documents = exclude_irrelevant_documents_to_scan(scan_type, git_head_documents) pre_committed_documents = exclude_irrelevant_documents_to_scan(scan_type, pre_committed_documents) sca_code_scanner.perform_pre_hook_range_scan_actions(git_head_documents, pre_committed_documents) scan_commit_range_documents( - context, + ctx, git_head_documents, pre_committed_documents, scan_parameters, @@ -61,11 +63,11 @@ def scan_sca_pre_commit(context: click.Context) -> None: ) -def scan_sca_commit_range(context: click.Context, path: str, commit_range: str) -> None: - scan_type = context.obj['scan_type'] - progress_bar = context.obj['progress_bar'] +def scan_sca_commit_range(ctx: typer.Context, path: str, commit_range: str) -> None: + scan_type = ctx.obj['scan_type'] + progress_bar = ctx.obj['progress_bar'] - scan_parameters = get_scan_parameters(context, (path,)) + scan_parameters = get_scan_parameters(ctx, (path,)) from_commit_rev, to_commit_rev = parse_commit_range(commit_range, path) from_commit_documents, to_commit_documents = get_commit_range_modified_documents( progress_bar, ScanProgressBarSection.PREPARE_LOCAL_FILES, path, from_commit_rev, to_commit_rev @@ -76,24 +78,24 @@ def scan_sca_commit_range(context: click.Context, path: str, commit_range: str) path, from_commit_documents, from_commit_rev, to_commit_documents, to_commit_rev ) - scan_commit_range_documents(context, from_commit_documents, to_commit_documents, scan_parameters=scan_parameters) + scan_commit_range_documents(ctx, from_commit_documents, to_commit_documents, scan_parameters=scan_parameters) -def scan_disk_files(context: click.Context, paths: Tuple[str]) -> None: - scan_parameters = get_scan_parameters(context, paths) - scan_type = context.obj['scan_type'] - progress_bar = context.obj['progress_bar'] +def scan_disk_files(ctx: typer.Context, paths: Tuple[str, ...]) -> None: + scan_parameters = get_scan_parameters(ctx, paths) + scan_type = ctx.obj['scan_type'] + progress_bar = ctx.obj['progress_bar'] try: documents = get_relevant_documents(progress_bar, ScanProgressBarSection.PREPARE_LOCAL_FILES, scan_type, paths) - perform_pre_scan_documents_actions(context, scan_type, documents) - scan_documents(context, documents, scan_parameters=scan_parameters) + perform_pre_scan_documents_actions(ctx, scan_type, documents) + scan_documents(ctx, documents, scan_parameters=scan_parameters) except Exception as e: - handle_scan_exception(context, e) + handle_scan_exception(ctx, e) -def set_issue_detected_by_scan_results(context: click.Context, scan_results: List[LocalScanResult]) -> None: - set_issue_detected(context, any(scan_result.issue_detected for scan_result in scan_results)) +def set_issue_detected_by_scan_results(ctx: typer.Context, scan_results: List[LocalScanResult]) -> None: + set_issue_detected(ctx, any(scan_result.issue_detected for scan_result in scan_results)) def _should_use_scan_service(scan_type: str, scan_parameters: dict) -> bool: @@ -150,13 +152,13 @@ def _enrich_scan_result_with_data_from_detection_rules( def _get_scan_documents_thread_func( - context: click.Context, is_git_diff: bool, is_commit_range: bool, scan_parameters: dict + ctx: typer.Context, is_git_diff: bool, is_commit_range: bool, scan_parameters: dict ) -> Tuple[Callable[[List[Document]], Tuple[str, CliError, LocalScanResult]], str]: - cycode_client = context.obj['client'] - scan_type = context.obj['scan_type'] - severity_threshold = context.obj['severity_threshold'] - sync_option = context.obj['sync'] - command_scan_type = context.info_name + cycode_client = ctx.obj['client'] + scan_type = ctx.obj['scan_type'] + severity_threshold = ctx.obj['severity_threshold'] + sync_option = ctx.obj['sync'] + command_scan_type = ctx.info_name aggregation_id = str(_generate_unique_id()) scan_parameters['aggregation_id'] = aggregation_id @@ -171,7 +173,7 @@ def _scan_batch_thread_func(batch: List[Document]) -> Tuple[str, CliError, Local should_use_sync_flow = _should_use_sync_flow(command_scan_type, scan_type, sync_option, scan_parameters) try: - logger.debug('Preparing local files, %s', {'batch_size': len(batch)}) + logger.debug('Preparing local files, %s', {'batch_files_count': len(batch)}) zipped_documents = zip_documents(scan_type, batch) zip_file_size = zipped_documents.size scan_result = perform_scan( @@ -194,7 +196,7 @@ def _scan_batch_thread_func(batch: List[Document]) -> Tuple[str, CliError, Local scan_completed = True except Exception as e: - error = handle_scan_exception(context, e, return_exception=True) + error = handle_scan_exception(ctx, e, return_exception=True) error_message = str(e) if local_scan_result: @@ -231,18 +233,18 @@ def _scan_batch_thread_func(batch: List[Document]) -> Tuple[str, CliError, Local def scan_commit_range( - context: click.Context, path: str, commit_range: str, max_commits_count: Optional[int] = None + ctx: typer.Context, path: str, commit_range: str, max_commits_count: Optional[int] = None ) -> None: - scan_type = context.obj['scan_type'] + scan_type = ctx.obj['scan_type'] - progress_bar = context.obj['progress_bar'] + progress_bar = ctx.obj['progress_bar'] progress_bar.start() if scan_type not in consts.COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES: raise click.ClickException(f'Commit range scanning for {str.upper(scan_type)} is not supported') if scan_type == consts.SCA_SCAN_TYPE: - return scan_sca_commit_range(context, path, commit_range) + return scan_sca_commit_range(ctx, path, commit_range) documents_to_scan = [] commit_ids_to_scan = [] @@ -287,26 +289,26 @@ def scan_commit_range( logger.debug('List of commit ids to scan, %s', {'commit_ids': commit_ids_to_scan}) logger.debug('Starting to scan commit range (it may take a few minutes)') - scan_documents(context, documents_to_scan, is_git_diff=True, is_commit_range=True) + scan_documents(ctx, documents_to_scan, is_git_diff=True, is_commit_range=True) return None def scan_documents( - context: click.Context, + ctx: typer.Context, documents_to_scan: List[Document], is_git_diff: bool = False, is_commit_range: bool = False, scan_parameters: Optional[dict] = None, ) -> None: if not scan_parameters: - scan_parameters = get_default_scan_parameters(context) + scan_parameters = get_default_scan_parameters(ctx) - scan_type = context.obj['scan_type'] - progress_bar = context.obj['progress_bar'] + scan_type = ctx.obj['scan_type'] + progress_bar = ctx.obj['progress_bar'] if not documents_to_scan: progress_bar.stop() - ConsolePrinter(context).print_error( + ConsolePrinter(ctx).print_error( CliError( code='no_relevant_files', message='Error: The scan could not be completed - relevant files to scan are not found. ' @@ -316,7 +318,7 @@ def scan_documents( return scan_batch_thread_func, aggregation_id = _get_scan_documents_thread_func( - context, is_git_diff, is_commit_range, scan_parameters + ctx, is_git_diff, is_commit_range, scan_parameters ) errors, local_scan_results = run_parallel_batched_scan( scan_batch_thread_func, scan_type, documents_to_scan, progress_bar=progress_bar @@ -325,20 +327,20 @@ def scan_documents( if len(local_scan_results) > 1: # if we used more than one batch, we need to fetch aggregate report url aggregation_report_url = _try_get_aggregation_report_url_if_needed( - scan_parameters, context.obj['client'], scan_type + scan_parameters, ctx.obj['client'], scan_type ) - set_aggregation_report_url(context, aggregation_report_url) + set_aggregation_report_url(ctx, aggregation_report_url) progress_bar.set_section_length(ScanProgressBarSection.GENERATE_REPORT, 1) progress_bar.update(ScanProgressBarSection.GENERATE_REPORT) progress_bar.stop() - set_issue_detected_by_scan_results(context, local_scan_results) - print_results(context, local_scan_results, errors) + set_issue_detected_by_scan_results(ctx, local_scan_results) + print_results(ctx, local_scan_results, errors) -def set_aggregation_report_url(context: click.Context, aggregation_report_url: Optional[str] = None) -> None: - context.obj['aggregation_report_url'] = aggregation_report_url +def set_aggregation_report_url(ctx: typer.Context, aggregation_report_url: Optional[str] = None) -> None: + ctx.obj['aggregation_report_url'] = aggregation_report_url def _try_get_aggregation_report_url_if_needed( @@ -357,7 +359,7 @@ def _try_get_aggregation_report_url_if_needed( def scan_commit_range_documents( - context: click.Context, + ctx: typer.Context, from_documents_to_scan: List[Document], to_documents_to_scan: List[Document], scan_parameters: Optional[dict] = None, @@ -365,11 +367,11 @@ def scan_commit_range_documents( ) -> None: """Used by SCA only""" - cycode_client = context.obj['client'] - scan_type = context.obj['scan_type'] - severity_threshold = context.obj['severity_threshold'] - scan_command_type = context.info_name - progress_bar = context.obj['progress_bar'] + cycode_client = ctx.obj['client'] + scan_type = ctx.obj['scan_type'] + severity_threshold = ctx.obj['severity_threshold'] + scan_command_type = ctx.info_name + progress_bar = ctx.obj['progress_bar'] local_scan_result = error_message = None scan_completed = False @@ -403,17 +405,17 @@ def scan_commit_range_documents( local_scan_result = create_local_scan_result( scan_result, to_documents_to_scan, scan_command_type, scan_type, severity_threshold ) - set_issue_detected_by_scan_results(context, [local_scan_result]) + set_issue_detected_by_scan_results(ctx, [local_scan_result]) progress_bar.update(ScanProgressBarSection.GENERATE_REPORT) progress_bar.stop() # errors will be handled with try-except block; printing will not occur on errors - print_results(context, [local_scan_result]) + print_results(ctx, [local_scan_result]) scan_completed = True except Exception as e: - handle_scan_exception(context, e) + handle_scan_exception(ctx, e) error_message = str(e) zip_file_size = from_commit_zipped_documents.size + to_commit_zipped_documents.size @@ -601,9 +603,9 @@ def print_debug_scan_details(scan_details_response: 'ScanDetailsResponse') -> No def print_results( - context: click.Context, local_scan_results: List[LocalScanResult], errors: Optional[Dict[str, 'CliError']] = None + ctx: typer.Context, local_scan_results: List[LocalScanResult], errors: Optional[Dict[str, 'CliError']] = None ) -> None: - printer = ConsolePrinter(context) + printer = ConsolePrinter(ctx) printer.print_scan_results(local_scan_results, errors) @@ -671,18 +673,18 @@ def parse_pre_receive_input() -> str: return pre_receive_input.splitlines()[0] -def get_default_scan_parameters(context: click.Context) -> dict: +def get_default_scan_parameters(ctx: typer.Context) -> dict: return { - 'monitor': context.obj.get('monitor'), - 'report': context.obj.get('report'), - 'package_vulnerabilities': context.obj.get('package-vulnerabilities'), - 'license_compliance': context.obj.get('license-compliance'), - 'command_type': context.info_name, + 'monitor': ctx.obj.get('monitor'), + 'report': ctx.obj.get('report'), + 'package_vulnerabilities': ctx.obj.get('package-vulnerabilities'), + 'license_compliance': ctx.obj.get('license-compliance'), + 'command_type': ctx.info_name, } -def get_scan_parameters(context: click.Context, paths: Tuple[str]) -> dict: - scan_parameters = get_default_scan_parameters(context) +def get_scan_parameters(ctx: typer.Context, paths: Tuple[str, ...]) -> dict: + scan_parameters = get_default_scan_parameters(ctx) if not paths: return scan_parameters @@ -696,7 +698,7 @@ def get_scan_parameters(context: click.Context, paths: Tuple[str]) -> dict: remote_url = try_get_git_remote_url(paths[0]) if remote_url: # TODO(MarshalX): remove hardcode in context - context.obj['remote_url'] = remote_url + ctx.obj['remote_url'] = remote_url scan_parameters['remote_url'] = remote_url return scan_parameters @@ -877,9 +879,9 @@ def _generate_unique_id() -> UUID: def _does_severity_match_severity_threshold(severity: str, severity_threshold: str) -> bool: - detection_severity_value = Severity.try_get_value(severity) - severity_threshold_value = Severity.try_get_value(severity_threshold) - if detection_severity_value is None or severity_threshold_value is None: + detection_severity_value = SeverityOption.get_member_weight(severity) + severity_threshold_value = SeverityOption.get_member_weight(severity_threshold) + if detection_severity_value < 0 or severity_threshold_value < 0: return True return detection_severity_value >= severity_threshold_value @@ -997,13 +999,13 @@ def _normalize_file_path(path: str) -> str: return path -def perform_post_pre_receive_scan_actions(context: click.Context) -> None: - if scan_utils.is_scan_failed(context): +def perform_post_pre_receive_scan_actions(ctx: typer.Context) -> None: + if scan_utils.is_scan_failed(ctx): click.echo(consts.PRE_RECEIVE_REMEDIATION_MESSAGE) -def enable_verbose_mode(context: click.Context) -> None: - context.obj['verbose'] = True +def enable_verbose_mode(ctx: typer.Context) -> None: + ctx.obj['verbose'] = True set_logging_level(logging.DEBUG) diff --git a/cycode/cli/commands/configure/__init__.py b/cycode/cli/apps/scan/commit_history/__init__.py similarity index 100% rename from cycode/cli/commands/configure/__init__.py rename to cycode/cli/apps/scan/commit_history/__init__.py diff --git a/cycode/cli/apps/scan/commit_history/commit_history_command.py b/cycode/cli/apps/scan/commit_history/commit_history_command.py new file mode 100644 index 00000000..dd74a4f0 --- /dev/null +++ b/cycode/cli/apps/scan/commit_history/commit_history_command.py @@ -0,0 +1,33 @@ +from pathlib import Path +from typing import Annotated + +import typer + +from cycode.cli.apps.scan.code_scanner import scan_commit_range +from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception +from cycode.cli.utils.sentry import add_breadcrumb +from cycode.cyclient import logger + + +def commit_history_command( + ctx: typer.Context, + path: Annotated[ + Path, typer.Argument(exists=True, resolve_path=True, help='Path to git repository to scan', show_default=False) + ], + commit_range: Annotated[ + str, + typer.Option( + '--commit_range', + '-r', + help='Scan a commit range in this git repository (example: HEAD~1)', + show_default='cycode scans all commit history', + ), + ] = '--all', +) -> None: + try: + add_breadcrumb('commit_history') + + logger.debug('Starting commit history scan process, %s', {'path': path, 'commit_range': commit_range}) + scan_commit_range(ctx, path=str(path), commit_range=commit_range) + except Exception as e: + handle_scan_exception(ctx, e) diff --git a/cycode/cli/commands/ignore/__init__.py b/cycode/cli/apps/scan/path/__init__.py similarity index 100% rename from cycode/cli/commands/ignore/__init__.py rename to cycode/cli/apps/scan/path/__init__.py diff --git a/cycode/cli/apps/scan/path/path_command.py b/cycode/cli/apps/scan/path/path_command.py new file mode 100644 index 00000000..4c841444 --- /dev/null +++ b/cycode/cli/apps/scan/path/path_command.py @@ -0,0 +1,25 @@ +from pathlib import Path +from typing import Annotated, List + +import typer + +from cycode.cli.apps.scan.code_scanner import scan_disk_files +from cycode.cli.utils.sentry import add_breadcrumb +from cycode.cyclient import logger + + +def path_command( + ctx: typer.Context, + paths: Annotated[ + List[Path], typer.Argument(exists=True, resolve_path=True, help='Paths to scan', show_default=False) + ], +) -> None: + add_breadcrumb('path') + + progress_bar = ctx.obj['progress_bar'] + progress_bar.start() + + logger.debug('Starting path scan process, %s', {'paths': paths}) + + tuple_paths = tuple(str(path) for path in paths) + scan_disk_files(ctx, tuple_paths) diff --git a/cycode/cli/commands/report/__init__.py b/cycode/cli/apps/scan/pre_commit/__init__.py similarity index 100% rename from cycode/cli/commands/report/__init__.py rename to cycode/cli/apps/scan/pre_commit/__init__.py diff --git a/cycode/cli/commands/scan/pre_commit/pre_commit_command.py b/cycode/cli/apps/scan/pre_commit/pre_commit_command.py similarity index 64% rename from cycode/cli/commands/scan/pre_commit/pre_commit_command.py rename to cycode/cli/apps/scan/pre_commit/pre_commit_command.py index fa4b295a..d88db8cc 100644 --- a/cycode/cli/commands/scan/pre_commit/pre_commit_command.py +++ b/cycode/cli/apps/scan/pre_commit/pre_commit_command.py @@ -1,37 +1,37 @@ import os -from typing import List +from typing import Annotated, List, Optional -import click +import typer from cycode.cli import consts -from cycode.cli.commands.scan.code_scanner import scan_documents, scan_sca_pre_commit +from cycode.cli.apps.scan.code_scanner import scan_documents, scan_sca_pre_commit from cycode.cli.files_collector.excluder import exclude_irrelevant_documents_to_scan from cycode.cli.files_collector.repository_documents import ( get_diff_file_content, 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, ) from cycode.cli.utils.progress_bar import ScanProgressBarSection +from cycode.cli.utils.sentry import add_breadcrumb -@click.command(short_help='Use this command to scan any content that was not committed yet.') -@click.argument('ignored_args', nargs=-1, type=click.UNPROCESSED) -@click.pass_context -def pre_commit_command(context: click.Context, ignored_args: List[str]) -> None: +def pre_commit_command( + ctx: typer.Context, + _: Annotated[Optional[List[str]], typer.Argument(help='Ignored arguments', hidden=True)] = None, +) -> None: add_breadcrumb('pre_commit') - scan_type = context.obj['scan_type'] + scan_type = ctx.obj['scan_type'] - progress_bar = context.obj['progress_bar'] + progress_bar = ctx.obj['progress_bar'] progress_bar.start() if scan_type == consts.SCA_SCAN_TYPE: - scan_sca_pre_commit(context) + scan_sca_pre_commit(ctx) return diff_files = git_proxy.get_repo(os.getcwd()).index.diff('HEAD', create_patch=True, R=True) @@ -44,4 +44,4 @@ def pre_commit_command(context: click.Context, ignored_args: List[str]) -> None: documents_to_scan.append(Document(get_path_by_os(get_diff_file_path(file)), get_diff_file_content(file))) documents_to_scan = exclude_irrelevant_documents_to_scan(scan_type, documents_to_scan) - scan_documents(context, documents_to_scan, is_git_diff=True) + scan_documents(ctx, documents_to_scan, is_git_diff=True) diff --git a/cycode/cli/commands/report/sbom/__init__.py b/cycode/cli/apps/scan/pre_receive/__init__.py similarity index 100% rename from cycode/cli/commands/report/sbom/__init__.py rename to cycode/cli/apps/scan/pre_receive/__init__.py diff --git a/cycode/cli/commands/scan/pre_receive/pre_receive_command.py b/cycode/cli/apps/scan/pre_receive/pre_receive_command.py similarity index 73% rename from cycode/cli/commands/scan/pre_receive/pre_receive_command.py rename to cycode/cli/apps/scan/pre_receive/pre_receive_command.py index 3ad59bad..92c152e6 100644 --- a/cycode/cli/commands/scan/pre_receive/pre_receive_command.py +++ b/cycode/cli/apps/scan/pre_receive/pre_receive_command.py @@ -1,10 +1,11 @@ import os -from typing import List +from typing import Annotated, List, Optional import click +import typer from cycode.cli import consts -from cycode.cli.commands.scan.code_scanner import ( +from cycode.cli.apps.scan.code_scanner import ( enable_verbose_mode, is_verbose_mode_requested_in_pre_receive_scan, parse_pre_receive_input, @@ -17,19 +18,19 @@ from cycode.cli.files_collector.repository_documents import ( calculate_pre_receive_commit_range, ) -from cycode.cli.sentry import add_breadcrumb +from cycode.cli.utils.sentry import add_breadcrumb from cycode.cli.utils.task_timer import TimeoutAfter from cycode.cyclient import logger -@click.command(short_help='Use this command to scan commits on the server side before pushing them to the repository.') -@click.argument('ignored_args', nargs=-1, type=click.UNPROCESSED) -@click.pass_context -def pre_receive_command(context: click.Context, ignored_args: List[str]) -> None: +def pre_receive_command( + ctx: typer.Context, + _: Annotated[Optional[List[str]], typer.Argument(help='Ignored arguments', hidden=True)] = None, +) -> None: try: add_breadcrumb('pre_receive') - scan_type = context.obj['scan_type'] + scan_type = ctx.obj['scan_type'] if scan_type != consts.SECRET_SCAN_TYPE: raise click.ClickException(f'Commit range scanning for {scan_type.upper()} is not supported') @@ -41,10 +42,10 @@ def pre_receive_command(context: click.Context, ignored_args: List[str]) -> None return if is_verbose_mode_requested_in_pre_receive_scan(): - enable_verbose_mode(context) + enable_verbose_mode(ctx) logger.debug('Verbose mode enabled: all log levels will be displayed.') - command_scan_type = context.info_name + command_scan_type = ctx.info_name timeout = configuration_manager.get_pre_receive_command_timeout(command_scan_type) with TimeoutAfter(timeout): if scan_type not in consts.COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES: @@ -60,7 +61,7 @@ def pre_receive_command(context: click.Context, ignored_args: List[str]) -> None return max_commits_to_scan = configuration_manager.get_pre_receive_max_commits_to_scan_count(command_scan_type) - scan_commit_range(context, os.getcwd(), commit_range, max_commits_count=max_commits_to_scan) - perform_post_pre_receive_scan_actions(context) + scan_commit_range(ctx, os.getcwd(), commit_range, max_commits_count=max_commits_to_scan) + perform_post_pre_receive_scan_actions(ctx) except Exception as e: - handle_scan_exception(context, e) + handle_scan_exception(ctx, e) diff --git a/cycode/cli/commands/report/sbom/path/__init__.py b/cycode/cli/apps/scan/repository/__init__.py similarity index 100% rename from cycode/cli/commands/report/sbom/path/__init__.py rename to cycode/cli/apps/scan/repository/__init__.py diff --git a/cycode/cli/commands/scan/repository/repository_command.py b/cycode/cli/apps/scan/repository/repository_command.py similarity index 66% rename from cycode/cli/commands/scan/repository/repository_command.py rename to cycode/cli/apps/scan/repository/repository_command.py index 9485c31c..0503c237 100644 --- a/cycode/cli/commands/scan/repository/repository_command.py +++ b/cycode/cli/apps/scan/repository/repository_command.py @@ -1,46 +1,46 @@ import os +from pathlib import Path +from typing import Annotated, Optional import click +import typer from cycode.cli import consts -from cycode.cli.commands.scan.code_scanner import get_scan_parameters, scan_documents +from cycode.cli.apps.scan.code_scanner import get_scan_parameters, scan_documents from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception from cycode.cli.files_collector.excluder import exclude_irrelevant_documents_to_scan 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.cli.utils.sentry import add_breadcrumb from cycode.cyclient import logger -@click.command(short_help='Scan the git repository including its history.') -@click.argument('path', nargs=1, type=click.Path(exists=True, resolve_path=True), required=True) -@click.option( - '--branch', - '-b', - default=None, - help='Branch to scan, if not set scanning the default branch', - type=str, - required=False, -) -@click.pass_context -def repository_command(context: click.Context, path: str, branch: str) -> None: +def repository_command( + ctx: typer.Context, + path: Annotated[ + Path, typer.Argument(exists=True, resolve_path=True, help='Path to git repository to scan.', show_default=False) + ], + branch: Annotated[ + Optional[str], typer.Option('--branch', '-b', help='Branch to scan.', show_default='default branch') + ] = None, +) -> None: try: add_breadcrumb('repository') logger.debug('Starting repository scan process, %s', {'path': path, 'branch': branch}) - scan_type = context.obj['scan_type'] - monitor = context.obj.get('monitor') + scan_type = ctx.obj['scan_type'] + monitor = ctx.obj.get('monitor') if monitor and scan_type != consts.SCA_SCAN_TYPE: raise click.ClickException('Monitor flag is currently supported for SCA scan type only') - progress_bar = context.obj['progress_bar'] + progress_bar = ctx.obj['progress_bar'] progress_bar.start() - file_entries = list(get_git_repository_tree_file_entries(path, branch)) + file_entries = list(get_git_repository_tree_file_entries(str(path), branch)) progress_bar.set_section_length(ScanProgressBarSection.PREPARE_LOCAL_FILES, len(file_entries)) documents_to_scan = [] @@ -60,10 +60,10 @@ def repository_command(context: click.Context, path: str, branch: str) -> None: documents_to_scan = exclude_irrelevant_documents_to_scan(scan_type, documents_to_scan) - perform_pre_scan_documents_actions(context, scan_type, documents_to_scan) + perform_pre_scan_documents_actions(ctx, scan_type, documents_to_scan) logger.debug('Found all relevant files for scanning %s', {'path': path, 'branch': branch}) - scan_parameters = get_scan_parameters(context, (path,)) - scan_documents(context, documents_to_scan, scan_parameters=scan_parameters) + scan_parameters = get_scan_parameters(ctx, (str(path),)) + scan_documents(ctx, documents_to_scan, scan_parameters=scan_parameters) except Exception as e: - handle_scan_exception(context, e) + handle_scan_exception(ctx, e) diff --git a/cycode/cli/commands/report/sbom/repository_url/__init__.py b/cycode/cli/apps/scan/scan_ci/__init__.py similarity index 100% rename from cycode/cli/commands/report/sbom/repository_url/__init__.py rename to cycode/cli/apps/scan/scan_ci/__init__.py diff --git a/cycode/cli/commands/scan/scan_ci/ci_integrations.py b/cycode/cli/apps/scan/scan_ci/ci_integrations.py similarity index 100% rename from cycode/cli/commands/scan/scan_ci/ci_integrations.py rename to cycode/cli/apps/scan/scan_ci/ci_integrations.py diff --git a/cycode/cli/apps/scan/scan_ci/scan_ci_command.py b/cycode/cli/apps/scan/scan_ci/scan_ci_command.py new file mode 100644 index 00000000..cbfebb72 --- /dev/null +++ b/cycode/cli/apps/scan/scan_ci/scan_ci_command.py @@ -0,0 +1,20 @@ +import os + +import click +import typer + +from cycode.cli.apps.scan.code_scanner import scan_commit_range +from cycode.cli.apps.scan.scan_ci.ci_integrations import get_commit_range +from cycode.cli.utils.sentry import add_breadcrumb + +# This command is not finished yet. It is not used in the codebase. + + +@click.command( + short_help='Execute scan in a CI environment which relies on the ' + 'CYCODE_TOKEN and CYCODE_REPO_LOCATION environment variables' +) +@click.pass_context +def scan_ci_command(ctx: typer.Context) -> None: + add_breadcrumb('ci') + scan_commit_range(ctx, path=os.getcwd(), commit_range=get_commit_range()) diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py new file mode 100644 index 00000000..dffbf34f --- /dev/null +++ b/cycode/cli/apps/scan/scan_command.py @@ -0,0 +1,148 @@ +from typing import Annotated, List, Optional + +import click +import typer + +from cycode.cli.cli_types import ScanTypeOption, ScaScanTypeOption, SeverityOption +from cycode.cli.config import config +from cycode.cli.consts import ( + ISSUE_DETECTED_STATUS_CODE, + NO_ISSUES_STATUS_CODE, + SCA_GRADLE_ALL_SUB_PROJECTS_FLAG, + SCA_SKIP_RESTORE_DEPENDENCIES_FLAG, +) +from cycode.cli.utils import scan_utils +from cycode.cli.utils.get_api_client import get_scan_cycode_client +from cycode.cli.utils.sentry import add_breadcrumb + + +def scan_command( + ctx: typer.Context, + scan_type: Annotated[ + ScanTypeOption, + typer.Option( + '--scan-type', + '-t', + help='Specify the type of scan you wish to execute.', + case_sensitive=False, + ), + ] = ScanTypeOption.SECRET, + client_secret: Annotated[ + Optional[str], + typer.Option( + help='Specify a Cycode client secret for this specific scan execution.', + rich_help_panel='Authentication options', + ), + ] = None, + client_id: Annotated[ + Optional[str], + typer.Option( + help='Specify a Cycode client ID for this specific scan execution.', + rich_help_panel='Authentication options', + ), + ] = None, + show_secret: Annotated[bool, typer.Option('--show-secret', help='Show Secrets in plain text.')] = False, + soft_fail: Annotated[ + bool, typer.Option('--soft-fail', help='Run the scan without failing; always return a non-error status code.') + ] = False, + severity_threshold: Annotated[ + SeverityOption, + typer.Option( + help='Show violations only for the specified level or higher.', + case_sensitive=False, + ), + ] = SeverityOption.INFO, + sync: Annotated[ + bool, + typer.Option('--sync', help='Run scan synchronously.', show_default='asynchronously'), + ] = False, + report: Annotated[ + bool, + typer.Option( + '--report', + help='When specified, generates a violations report. ' + 'A link to the report will be displayed in the console output.', + ), + ] = False, + sca_scan: Annotated[ + List[ScaScanTypeOption], + typer.Option( + help='Specify the type of SCA scan you wish to execute.', + rich_help_panel='SCA options', + ), + ] = (ScaScanTypeOption.PACKAGE_VULNERABILITIES, ScaScanTypeOption.LICENSE_COMPLIANCE), + monitor: Annotated[ + bool, + typer.Option( + '--monitor', + help='When specified, the scan results are recorded in the Discovery module.', + rich_help_panel='SCA options', + ), + ] = False, + no_restore: Annotated[ + bool, + typer.Option( + f'--{SCA_SKIP_RESTORE_DEPENDENCIES_FLAG}', + help='When specified, Cycode will not run restore command. ' + 'Will scan direct dependencies [bold]only[/bold]!', + rich_help_panel='SCA options', + ), + ] = False, + gradle_all_sub_projects: Annotated[ + bool, + typer.Option( + f'--{SCA_GRADLE_ALL_SUB_PROJECTS_FLAG}', + help='When specified, Cycode will run gradle restore command for all sub projects. ' + 'Should run from root project directory [bold]only[/bold]!', + rich_help_panel='SCA options', + ), + ] = False, +) -> None: + """:magnifying_glass_tilted_right: Scan the content for Secrets, IaC, SCA, and SAST violations. + You'll need to specify which scan type to perform: + [cyan]path[/cyan]/[cyan]repository[/cyan]/[cyan]commit_history[/cyan].""" + add_breadcrumb('scan') + + if show_secret: + ctx.obj['show_secret'] = show_secret + else: + ctx.obj['show_secret'] = config['result_printer']['default']['show_secret'] + + if soft_fail: + ctx.obj['soft_fail'] = soft_fail + else: + ctx.obj['soft_fail'] = config['soft_fail'] + + ctx.obj['client'] = get_scan_cycode_client(client_id, client_secret, not ctx.obj['show_secret']) + ctx.obj['scan_type'] = scan_type + ctx.obj['sync'] = sync + ctx.obj['severity_threshold'] = severity_threshold + ctx.obj['monitor'] = monitor + ctx.obj['report'] = report + ctx.obj[SCA_SKIP_RESTORE_DEPENDENCIES_FLAG] = no_restore + ctx.obj[SCA_GRADLE_ALL_SUB_PROJECTS_FLAG] = gradle_all_sub_projects + + _sca_scan_to_context(ctx, sca_scan) + + +def _sca_scan_to_context(ctx: typer.Context, sca_scan_user_selected: List[str]) -> None: + for sca_scan_option_selected in sca_scan_user_selected: + ctx.obj[sca_scan_option_selected] = True + + +@click.pass_context +def scan_command_result_callback(ctx: click.Context, *_, **__) -> None: + add_breadcrumb('scan_finalize') + + progress_bar = ctx.obj.get('progress_bar') + if progress_bar: + progress_bar.stop() + + if ctx.obj['soft_fail']: + raise typer.Exit(0) + + exit_code = NO_ISSUES_STATUS_CODE + if scan_utils.is_scan_failed(ctx): + exit_code = ISSUE_DETECTED_STATUS_CODE + + raise typer.Exit(exit_code) diff --git a/cycode/cli/apps/status/__init__.py b/cycode/cli/apps/status/__init__.py new file mode 100644 index 00000000..f01e3b30 --- /dev/null +++ b/cycode/cli/apps/status/__init__.py @@ -0,0 +1,8 @@ +import typer + +from cycode.cli.apps.status.status_command import status_command +from cycode.cli.apps.status.version_command import version_command + +app = typer.Typer() +app.command(name='status', short_help='Show the CLI status and exit.')(status_command) +app.command(name='version', hidden=True, short_help='Alias to status command.')(version_command) diff --git a/cycode/cli/apps/status/get_cli_status.py b/cycode/cli/apps/status/get_cli_status.py new file mode 100644 index 00000000..e58e910b --- /dev/null +++ b/cycode/cli/apps/status/get_cli_status.py @@ -0,0 +1,45 @@ +import platform + +from cycode import __version__ +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 +from cycode.cli.user_settings.configuration_manager import ConfigurationManager +from cycode.cli.utils.get_api_client import get_scan_cycode_client +from cycode.cyclient import logger + + +def get_cli_status() -> CliStatus: + configuration_manager = ConfigurationManager() + + auth_info = get_authorization_info() + is_authenticated = auth_info is not None + + supported_modules_status = CliSupportedModulesStatus() + if is_authenticated: + try: + client = get_scan_cycode_client() + supported_modules_preferences = client.get_supported_modules_preferences() + + supported_modules_status.secret_scanning = supported_modules_preferences.secret_scanning + supported_modules_status.sca_scanning = supported_modules_preferences.sca_scanning + supported_modules_status.iac_scanning = supported_modules_preferences.iac_scanning + supported_modules_status.sast_scanning = supported_modules_preferences.sast_scanning + supported_modules_status.ai_large_language_model = supported_modules_preferences.ai_large_language_model + except Exception as e: + logger.debug('Failed to get supported modules preferences', exc_info=e) + + return CliStatus( + program=PROGRAM_NAME, + version=__version__, + os=platform.system(), + arch=platform.machine(), + python_version=platform.python_version(), + installation_id=configuration_manager.get_or_create_installation_id(), + app_url=configuration_manager.get_cycode_app_url(), + api_url=configuration_manager.get_cycode_api_url(), + is_authenticated=is_authenticated, + user_id=auth_info.user_id if auth_info else None, + tenant_id=auth_info.tenant_id if auth_info else None, + supported_modules=supported_modules_status, + ) diff --git a/cycode/cli/apps/status/models.py b/cycode/cli/apps/status/models.py new file mode 100644 index 00000000..50182ecd --- /dev/null +++ b/cycode/cli/apps/status/models.py @@ -0,0 +1,62 @@ +import json +from dataclasses import asdict, dataclass +from typing import Dict + + +class CliStatusBase: + def as_dict(self) -> Dict[str, any]: + return asdict(self) + + def _get_text_message_part(self, key: str, value: any, intent: int = 0) -> str: + message_parts = [] + + intent_prefix = ' ' * intent * 2 + human_readable_key = key.replace('_', ' ').capitalize() + + if isinstance(value, dict): + message_parts.append(f'{intent_prefix}{human_readable_key}:') + for sub_key, sub_value in value.items(): + message_parts.append(self._get_text_message_part(sub_key, sub_value, intent=intent + 1)) + elif isinstance(value, (list, set, tuple)): + message_parts.append(f'{intent_prefix}{human_readable_key}:') + for index, sub_value in enumerate(value): + message_parts.append(self._get_text_message_part(f'#{index}', sub_value, intent=intent + 1)) + else: + message_parts.append(f'{intent_prefix}{human_readable_key}: {value}') + + return '\n'.join(message_parts) + + def as_text(self) -> str: + message_parts = [] + for key, value in self.as_dict().items(): + message_parts.append(self._get_text_message_part(key, value)) + + return '\n'.join(message_parts) + + def as_json(self) -> str: + return json.dumps(self.as_dict()) + + +@dataclass +class CliSupportedModulesStatus(CliStatusBase): + secret_scanning: bool = False + sca_scanning: bool = False + iac_scanning: bool = False + sast_scanning: bool = False + ai_large_language_model: bool = False + + +@dataclass +class CliStatus(CliStatusBase): + program: str + version: str + os: str + arch: str + python_version: str + installation_id: str + app_url: str + api_url: str + is_authenticated: bool + user_id: str = None + tenant_id: str = None + supported_modules: CliSupportedModulesStatus = None diff --git a/cycode/cli/apps/status/status_command.py b/cycode/cli/apps/status/status_command.py new file mode 100644 index 00000000..edffd24c --- /dev/null +++ b/cycode/cli/apps/status/status_command.py @@ -0,0 +1,15 @@ +import typer + +from cycode.cli.apps.status.get_cli_status import get_cli_status +from cycode.cli.cli_types import OutputTypeOption + + +def status_command(ctx: typer.Context) -> None: + output = ctx.obj['output'] + + cli_status = get_cli_status() + message = cli_status.as_text() + if output == OutputTypeOption.JSON: + message = cli_status.as_json() + + typer.echo(message, color=ctx.color) diff --git a/cycode/cli/apps/status/version_command.py b/cycode/cli/apps/status/version_command.py new file mode 100644 index 00000000..c36aad4b --- /dev/null +++ b/cycode/cli/apps/status/version_command.py @@ -0,0 +1,15 @@ +import typer + +from cycode.cli.apps.status.status_command import status_command + + +def version_command(ctx: typer.Context) -> None: + typer.echo( + typer.style( + text='The "version" command is deprecated. Please use the "status" command instead.', + fg=typer.colors.YELLOW, + bold=True, + ), + color=ctx.color, + ) + status_command(ctx) diff --git a/cycode/cli/cli_types.py b/cycode/cli/cli_types.py new file mode 100644 index 00000000..92c36fa2 --- /dev/null +++ b/cycode/cli/cli_types.py @@ -0,0 +1,53 @@ +from enum import Enum + +from cycode.cli import consts + + +class OutputTypeOption(str, Enum): + TEXT = 'text' + JSON = 'json' + TABLE = 'table' + + +class ScanTypeOption(str, Enum): + SECRET = consts.SECRET_SCAN_TYPE + SCA = consts.SCA_SCAN_TYPE + IAC = consts.IAC_SCAN_TYPE + SAST = consts.SAST_SCAN_TYPE + + +class ScaScanTypeOption(str, Enum): + PACKAGE_VULNERABILITIES = 'package-vulnerabilities' + LICENSE_COMPLIANCE = 'license-compliance' + + +class SbomFormatOption(str, Enum): + SPDX_2_2 = 'spdx-2.2' + SPDX_2_3 = 'spdx-2.3' + CYCLONEDX_1_4 = 'cyclonedx-1.4' + + +class SbomOutputFormatOption(str, Enum): + JSON = 'json' + + +class SeverityOption(str, Enum): + INFO = 'info' + LOW = 'low' + MEDIUM = 'medium' + HIGH = 'high' + CRITICAL = 'critical' + + @staticmethod + def get_member_weight(name: str) -> int: + return _SEVERITY_WEIGHTS.get(name.lower(), _SEVERITY_DEFAULT_WEIGHT) + + +_SEVERITY_DEFAULT_WEIGHT = -1 +_SEVERITY_WEIGHTS = { + SeverityOption.INFO.value: 0, + SeverityOption.LOW.value: 1, + SeverityOption.MEDIUM.value: 2, + SeverityOption.HIGH.value: 3, + SeverityOption.CRITICAL.value: 4, +} diff --git a/cycode/cli/commands/ai_remediation/ai_remediation_command.py b/cycode/cli/commands/ai_remediation/ai_remediation_command.py deleted file mode 100644 index 608fc9f4..00000000 --- a/cycode/cli/commands/ai_remediation/ai_remediation_command.py +++ /dev/null @@ -1,67 +0,0 @@ -import os - -import click -from patch_ng import fromstring -from rich.console import Console -from rich.markdown import Markdown - -from cycode.cli.exceptions.handle_ai_remediation_errors import handle_ai_remediation_exception -from cycode.cli.models import CliResult -from cycode.cli.printers import ConsolePrinter -from cycode.cli.utils.get_api_client import get_scan_cycode_client - - -def _echo_remediation(context: click.Context, remediation_markdown: str, is_fix_available: bool) -> None: - printer = ConsolePrinter(context) - if printer.is_json_printer: - data = {'remediation': remediation_markdown, 'is_fix_available': is_fix_available} - printer.print_result(CliResult(success=True, message='Remediation fetched successfully', data=data)) - else: # text or table - Console().print(Markdown(remediation_markdown)) - - -def _apply_fix(context: click.Context, diff: str, is_fix_available: bool) -> None: - printer = ConsolePrinter(context) - if not is_fix_available: - printer.print_result(CliResult(success=False, message='Fix is not available for this violation')) - return - - patch = fromstring(diff.encode('UTF-8')) - if patch is False: - printer.print_result(CliResult(success=False, message='Failed to parse fix diff')) - return - - is_fix_applied = patch.apply(root=os.getcwd(), strip=0) - if is_fix_applied: - printer.print_result(CliResult(success=True, message='Fix applied successfully')) - else: - printer.print_result(CliResult(success=False, message='Failed to apply fix')) - - -@click.command(short_help='Get AI remediation (INTERNAL).', hidden=True) -@click.argument('detection_id', nargs=1, type=click.UUID, required=True) -@click.option( - '--fix', - is_flag=True, - default=False, - help='Apply fixes to resolve violations. Fix is not available for all violations.', - type=click.BOOL, - required=False, -) -@click.pass_context -def ai_remediation_command(context: click.Context, detection_id: str, fix: bool) -> None: - client = get_scan_cycode_client() - - try: - remediation_markdown = client.get_ai_remediation(detection_id) - fix_diff = client.get_ai_remediation(detection_id, fix=True) - is_fix_available = bool(fix_diff) # exclude empty string, None, etc. - - if fix: - _apply_fix(context, fix_diff, is_fix_available) - else: - _echo_remediation(context, remediation_markdown, is_fix_available) - except Exception as err: - handle_ai_remediation_exception(context, err) - - context.exit() diff --git a/cycode/cli/commands/auth/auth_command.py b/cycode/cli/commands/auth/auth_command.py deleted file mode 100644 index 0862db2b..00000000 --- a/cycode/cli/commands/auth/auth_command.py +++ /dev/null @@ -1,82 +0,0 @@ -import click - -from cycode.cli.commands.auth.auth_manager import AuthManager -from cycode.cli.commands.auth_common import get_authorization_info -from cycode.cli.exceptions.custom_exceptions import ( - KNOWN_USER_FRIENDLY_REQUEST_ERRORS, - AuthProcessError, -) -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.cyclient import logger - - -@click.group( - invoke_without_command=True, short_help='Authenticate your machine to associate the CLI with your Cycode account.' -) -@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 - - try: - logger.debug('Starting authentication process') - - auth_manager = AuthManager() - auth_manager.authenticate() - - result = CliResult(success=True, message='Successfully logged into cycode') - ConsolePrinter(context).print_result(result) - except Exception as e: - _handle_exception(context, e) - - -@auth_command.command( - name='check', short_help='Checks that your machine is associating the CLI with your Cycode account.' -) -@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) - auth_info = get_authorization_info(context) - if auth_info is None: - printer.print_result(CliResult(success=False, message='Cycode authentication failed')) - return - - printer.print_result( - CliResult( - success=True, - message='Cycode authentication verified', - data={'user_id': auth_info.user_id, 'tenant_id': auth_info.tenant_id}, - ) - ) - - -def _handle_exception(context: click.Context, e: Exception) -> None: - ConsolePrinter(context).print_exception() - - errors: CliErrors = { - **KNOWN_USER_FRIENDLY_REQUEST_ERRORS, - AuthProcessError: CliError( - code='auth_error', message='Authentication failed. Please try again later using the command `cycode auth`' - ), - } - - error = errors.get(type(e)) - if error: - ConsolePrinter(context).print_error(error) - return - - 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 deleted file mode 100644 index 8f76d159..00000000 --- a/cycode/cli/commands/configure/configure_command.py +++ /dev/null @@ -1,140 +0,0 @@ -from typing import Optional - -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 - -_URLS_UPDATED_SUCCESSFULLY_MESSAGE = 'Successfully configured Cycode URLs! Saved to: {filename}' -_URLS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE = ( - 'Note that the URLs (APP and API) that already exist in environment variables ' - f'({consts.CYCODE_API_URL_ENV_VAR_NAME} and {consts.CYCODE_APP_URL_ENV_VAR_NAME}) ' - 'take precedent over these URLs; either update or remove the environment variables.' -) -_CREDENTIALS_UPDATED_SUCCESSFULLY_MESSAGE = 'Successfully configured CLI credentials! Saved to: {filename}' -_CREDENTIALS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE = ( - 'Note that the credentials that already exist in environment variables ' - f'({config.CYCODE_CLIENT_ID_ENV_VAR_NAME} and {config.CYCODE_CLIENT_SECRET_ENV_VAR_NAME}) ' - 'take precedent over these credentials; either update or remove the environment variables.' -) -_CREDENTIALS_MANAGER = CredentialsManager() -_CONFIGURATION_MANAGER = ConfigurationManager() - - -@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() - current_app_url = global_config_manager.get_app_url() - api_url = _get_api_url_input(current_api_url) - app_url = _get_app_url_input(current_app_url) - - config_updated = False - if _should_update_value(current_api_url, api_url): - global_config_manager.update_api_base_url(api_url) - config_updated = True - if _should_update_value(current_app_url, app_url): - global_config_manager.update_app_base_url(app_url) - config_updated = True - - current_client_id, current_client_secret = _CREDENTIALS_MANAGER.get_credentials_from_file() - client_id = _get_client_id_input(current_client_id) - client_secret = _get_client_secret_input(current_client_secret) - - credentials_updated = False - if _should_update_value(current_client_id, client_id) or _should_update_value(current_client_secret, client_secret): - credentials_updated = True - _CREDENTIALS_MANAGER.update_credentials(client_id, client_secret) - - if config_updated: - click.echo(_get_urls_update_result_message()) - if credentials_updated: - click.echo(_get_credentials_update_result_message()) - - -def _get_client_id_input(current_client_id: Optional[str]) -> Optional[str]: - prompt_text = 'Cycode Client ID' - - prompt_suffix = ' []: ' - if current_client_id: - prompt_suffix = f' [{obfuscate_text(current_client_id)}]: ' - - new_client_id = click.prompt(text=prompt_text, prompt_suffix=prompt_suffix, default='', show_default=False) - return new_client_id or current_client_id - - -def _get_client_secret_input(current_client_secret: Optional[str]) -> Optional[str]: - prompt_text = 'Cycode Client Secret' - - prompt_suffix = ' []: ' - if current_client_secret: - prompt_suffix = f' [{obfuscate_text(current_client_secret)}]: ' - - new_client_secret = click.prompt(text=prompt_text, prompt_suffix=prompt_suffix, default='', show_default=False) - return new_client_secret or current_client_secret - - -def _get_app_url_input(current_app_url: Optional[str]) -> str: - prompt_text = 'Cycode APP URL' - - default = consts.DEFAULT_CYCODE_APP_URL - if current_app_url: - default = current_app_url - - return click.prompt(text=prompt_text, default=default, type=click.STRING) - - -def _get_api_url_input(current_api_url: Optional[str]) -> str: - prompt_text = 'Cycode API URL' - - default = consts.DEFAULT_CYCODE_API_URL - if current_api_url: - default = current_api_url - - return click.prompt(text=prompt_text, default=default, type=click.STRING) - - -def _get_credentials_update_result_message() -> str: - success_message = _CREDENTIALS_UPDATED_SUCCESSFULLY_MESSAGE.format(filename=_CREDENTIALS_MANAGER.get_filename()) - if _are_credentials_exist_in_environment_variables(): - return f'{success_message}. {_CREDENTIALS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE}' - - return success_message - - -def _are_credentials_exist_in_environment_variables() -> bool: - client_id, client_secret = _CREDENTIALS_MANAGER.get_credentials_from_environment_variables() - return any([client_id, client_secret]) - - -def _get_urls_update_result_message() -> str: - success_message = _URLS_UPDATED_SUCCESSFULLY_MESSAGE.format( - filename=_CONFIGURATION_MANAGER.global_config_file_manager.get_filename() - ) - if _are_urls_exist_in_environment_variables(): - return f'{success_message}. {_URLS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE}' - - return success_message - - -def _are_urls_exist_in_environment_variables() -> bool: - api_url = _CONFIGURATION_MANAGER.get_api_url_from_environment_variables() - app_url = _CONFIGURATION_MANAGER.get_app_url_from_environment_variables() - return any([api_url, app_url]) - - -def _should_update_value( - old_value: Optional[str], - new_value: Optional[str], -) -> bool: - if not new_value: - return False - - return old_value != new_value diff --git a/cycode/cli/commands/main_cli.py b/cycode/cli/commands/main_cli.py deleted file mode 100644 index 59b8625f..00000000 --- a/cycode/cli/commands/main_cli.py +++ /dev/null @@ -1,117 +0,0 @@ -import logging -from typing import Optional - -import click - -from cycode import __version__ -from cycode.cli.commands.ai_remediation.ai_remediation_command import ai_remediation_command -from cycode.cli.commands.auth.auth_command import auth_command -from cycode.cli.commands.configure.configure_command import configure_command -from cycode.cli.commands.ignore.ignore_command import ignore_command -from cycode.cli.commands.report.report_command import report_command -from cycode.cli.commands.scan.scan_command import scan_command -from cycode.cli.commands.status.status_command import status_command -from cycode.cli.commands.version.version_checker import version_checker -from cycode.cli.commands.version.version_command import version_command -from cycode.cli.consts import ( - CLI_CONTEXT_SETTINGS, -) -from cycode.cli.sentry import add_breadcrumb, init_sentry -from cycode.cli.user_settings.configuration_manager import ConfigurationManager -from cycode.cli.utils.progress_bar import SCAN_PROGRESS_BAR_SECTIONS, get_progress_bar -from cycode.cyclient.config import set_logging_level -from cycode.cyclient.cycode_client_base import CycodeClientBase -from cycode.cyclient.models import UserAgentOptionScheme - - -@click.group( - commands={ - 'scan': scan_command, - 'report': report_command, - 'configure': configure_command, - 'ignore': ignore_command, - 'auth': auth_command, - 'version': version_command, - 'status': status_command, - 'ai_remediation': ai_remediation_command, - }, - context_settings=CLI_CONTEXT_SETTINGS, -) -@click.option( - '--verbose', - '-v', - is_flag=True, - default=False, - help='Show detailed logs.', -) -@click.option( - '--no-progress-meter', - is_flag=True, - default=False, - help='Do not show the progress meter.', -) -@click.option( - '--no-update-notifier', - is_flag=True, - default=False, - help='Do not check CLI for updates.', -) -@click.option( - '--output', - '-o', - default='text', - help='Specify the output type (the default is text).', - type=click.Choice(['text', 'json', 'table']), -) -@click.option( - '--user-agent', - default=None, - help='Characteristic JSON object that lets servers identify the application.', - type=str, -) -@click.pass_context -def main_cli( - context: click.Context, - verbose: bool, - no_progress_meter: bool, - no_update_notifier: bool, - output: str, - user_agent: Optional[str], -) -> None: - init_sentry() - add_breadcrumb('cycode') - - context.ensure_object(dict) - configuration_manager = ConfigurationManager() - - verbose = verbose or configuration_manager.get_verbose_flag() - context.obj['verbose'] = verbose - if verbose: - set_logging_level(logging.DEBUG) - - context.obj['output'] = output - if output == 'json': - no_progress_meter = True - - context.obj['progress_bar'] = get_progress_bar(hidden=no_progress_meter, sections=SCAN_PROGRESS_BAR_SECTIONS) - - if user_agent: - user_agent_option = UserAgentOptionScheme().loads(user_agent) - CycodeClientBase.enrich_user_agent(user_agent_option.user_agent_suffix) - - if not no_update_notifier: - context.call_on_close(lambda: check_latest_version_on_close()) - - -@click.pass_context -def check_latest_version_on_close(context: click.Context) -> None: - output = context.obj.get('output') - # don't print anything if the output is JSON - if output == 'json': - return - - # we always want to check the latest version for "version" and "status" commands - should_use_cache = context.invoked_subcommand not in {'version', 'status'} - version_checker.check_and_notify_update( - current_version=__version__, use_color=context.color, use_cache=should_use_cache - ) diff --git a/cycode/cli/commands/report/report_command.py b/cycode/cli/commands/report/report_command.py deleted file mode 100644 index 9e92a64f..00000000 --- a/cycode/cli/commands/report/report_command.py +++ /dev/null @@ -1,21 +0,0 @@ -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 - - -@click.group( - commands={ - 'sbom': sbom_command, - }, - short_help='Generate report. You`ll need to specify which report type to perform.', -) -@click.pass_context -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/sbom_command.py b/cycode/cli/commands/report/sbom/sbom_command.py deleted file mode 100644 index a938fd90..00000000 --- a/cycode/cli/commands/report/sbom/sbom_command.py +++ /dev/null @@ -1,87 +0,0 @@ -import pathlib -from typing import Optional - -import click - -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 - - -@click.group( - commands={ - 'path': path_command, - 'repository_url': repository_url_command, - }, - short_help='Generate SBOM report for remote repository by url or local directory by path.', -) -@click.option( - '--format', - '-f', - help='SBOM format.', - type=click.Choice(config['scans']['supported_sbom_formats']), - required=True, -) -@click.option( - '--output-format', - '-o', - default='json', - help='Specify the output file format (the default is json).', - type=click.Choice(['json']), - required=False, -) -@click.option( - '--output-file', - help='Output file (the default is autogenerated filename saved to the current directory).', - default=None, - type=click.Path(resolve_path=True, writable=True, path_type=pathlib.Path), - required=False, -) -@click.option( - '--include-vulnerabilities', - is_flag=True, - default=False, - help='Include vulnerabilities.', - type=bool, - required=False, -) -@click.option( - '--include-dev-dependencies', - is_flag=True, - default=False, - help='Include dev dependencies.', - type=bool, - required=False, -) -@click.pass_context -def sbom_command( - context: click.Context, - format: str, - output_format: Optional[str], - output_file: Optional[pathlib.Path], - include_vulnerabilities: bool, - 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.') - - sbom_format, sbom_format_version = sbom_format_parts - - report_parameters = ReportParameters( - entity_type='SbomCli', - sbom_report_type=sbom_format, - sbom_version=sbom_format_version, - output_format=output_format, - include_vulnerabilities=include_vulnerabilities, - include_dev_dependencies=include_dev_dependencies, - ) - context.obj['report_parameters'] = report_parameters - context.obj['output_file'] = output_file - - return 1 diff --git a/cycode/cli/commands/scan/__init__.py b/cycode/cli/commands/scan/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/cycode/cli/commands/scan/commit_history/__init__.py b/cycode/cli/commands/scan/commit_history/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/cycode/cli/commands/scan/commit_history/commit_history_command.py b/cycode/cli/commands/scan/commit_history/commit_history_command.py deleted file mode 100644 index bfb57c29..00000000 --- a/cycode/cli/commands/scan/commit_history/commit_history_command.py +++ /dev/null @@ -1,27 +0,0 @@ -import click - -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 - - -@click.command(short_help='Scan all the commits history in this git repository.') -@click.argument('path', nargs=1, type=click.Path(exists=True, resolve_path=True), required=True) -@click.option( - '--commit_range', - '-r', - help='Scan a commit range in this git repository, by default cycode scans all commit history (example: HEAD~1)', - type=click.STRING, - default='--all', - required=False, -) -@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: - handle_scan_exception(context, e) diff --git a/cycode/cli/commands/scan/path/__init__.py b/cycode/cli/commands/scan/path/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/cycode/cli/commands/scan/path/path_command.py b/cycode/cli/commands/scan/path/path_command.py deleted file mode 100644 index ec62b224..00000000 --- a/cycode/cli/commands/scan/path/path_command.py +++ /dev/null @@ -1,20 +0,0 @@ -from typing import Tuple - -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 - - -@click.command(short_help='Scan the files in the path provided in the command.') -@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() - - logger.debug('Starting path scan process, %s', {'paths': paths}) - scan_disk_files(context, paths) diff --git a/cycode/cli/commands/scan/pre_commit/__init__.py b/cycode/cli/commands/scan/pre_commit/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/cycode/cli/commands/scan/pre_receive/__init__.py b/cycode/cli/commands/scan/pre_receive/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/cycode/cli/commands/scan/repository/__init__.py b/cycode/cli/commands/scan/repository/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/cycode/cli/commands/scan/scan_ci/__init__.py b/cycode/cli/commands/scan/scan_ci/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/cycode/cli/commands/scan/scan_ci/scan_ci_command.py b/cycode/cli/commands/scan/scan_ci/scan_ci_command.py deleted file mode 100644 index 6d4fbd36..00000000 --- a/cycode/cli/commands/scan/scan_ci/scan_ci_command.py +++ /dev/null @@ -1,19 +0,0 @@ -import os - -import click - -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. - - -@click.command( - short_help='Execute scan in a CI environment which relies on the ' - 'CYCODE_TOKEN and CYCODE_REPO_LOCATION environment variables' -) -@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 deleted file mode 100644 index 95259f4a..00000000 --- a/cycode/cli/commands/scan/scan_command.py +++ /dev/null @@ -1,187 +0,0 @@ -import sys -from typing import List - -import click - -from cycode.cli import consts -from cycode.cli.commands.scan.commit_history.commit_history_command import commit_history_command -from cycode.cli.commands.scan.path.path_command import path_command -from cycode.cli.commands.scan.pre_commit.pre_commit_command import pre_commit_command -from cycode.cli.commands.scan.pre_receive.pre_receive_command import pre_receive_command -from cycode.cli.commands.scan.repository.repository_command import repository_command -from cycode.cli.config import config -from cycode.cli.consts import ( - ISSUE_DETECTED_STATUS_CODE, - NO_ISSUES_STATUS_CODE, - SCA_GRADLE_ALL_SUB_PROJECTS_FLAG, - 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 - - -@click.group( - commands={ - 'repository': repository_command, - 'commit_history': commit_history_command, - 'path': path_command, - 'pre_commit': pre_commit_command, - 'pre_receive': pre_receive_command, - }, - short_help='Scan the content for Secrets/IaC/SCA/SAST violations. ' - 'You`ll need to specify which scan type to perform: commit_history/path/repository/etc.', -) -@click.option( - '--scan-type', - '-t', - default=consts.SECRET_SCAN_TYPE, - help='Specify the type of scan you wish to execute (the default is Secrets).', - type=click.Choice(config['scans']['supported_scans']), -) -@click.option( - '--secret', - default=None, - help='Specify a Cycode client secret for this specific scan execution.', - type=str, - required=False, -) -@click.option( - '--client-id', - default=None, - help='Specify a Cycode client ID for this specific scan execution.', - type=str, - required=False, -) -@click.option( - '--show-secret', is_flag=True, default=False, help='Show Secrets in plain text.', type=bool, required=False -) -@click.option( - '--soft-fail', - is_flag=True, - default=False, - help='Run the scan without failing; always return a non-error status code.', - type=bool, - required=False, -) -@click.option( - '--severity-threshold', - default=Severity.INFO.name, - help='Show violations only for the specified level or higher.', - type=click.Choice([e.name for e in Severity]), - required=False, -) -@click.option( - '--sca-scan', - default=None, - help='Specify the type of SCA scan you wish to execute (the default is both).', - multiple=True, - type=click.Choice(config['scans']['supported_sca_scans']), -) -@click.option( - '--monitor', - is_flag=True, - default=False, - help='Used for SCA scan types only; when specified, the scan results are recorded in the Discovery module.', - type=bool, - required=False, -) -@click.option( - '--report', - is_flag=True, - default=False, - help='When specified, generates a violations report. A link to the report will be displayed in the console output.', - type=bool, - required=False, -) -@click.option( - f'--{SCA_SKIP_RESTORE_DEPENDENCIES_FLAG}', - is_flag=True, - default=False, - help='When specified, Cycode will not run restore command. Will scan direct dependencies ONLY!', - type=bool, - required=False, -) -@click.option( - '--sync', - is_flag=True, - default=False, - help='Run scan synchronously (the default is asynchronous).', - type=bool, - required=False, -) -@click.option( - f'--{SCA_GRADLE_ALL_SUB_PROJECTS_FLAG}', - is_flag=True, - default=False, - help='When specified, Cycode will run gradle restore command for all sub projects. ' - 'Should run from root project directory ONLY!', - type=bool, - required=False, -) -@click.pass_context -def scan_command( - context: click.Context, - scan_type: str, - secret: str, - client_id: str, - show_secret: bool, - soft_fail: bool, - severity_threshold: str, - sca_scan: List[str], - monitor: bool, - report: bool, - no_restore: bool, - sync: bool, - gradle_all_sub_projects: bool, -) -> int: - """Scans for Secrets, IaC, SCA or SAST violations.""" - add_breadcrumb('scan') - - if show_secret: - context.obj['show_secret'] = show_secret - else: - context.obj['show_secret'] = config['result_printer']['default']['show_secret'] - - if soft_fail: - context.obj['soft_fail'] = soft_fail - else: - context.obj['soft_fail'] = config['soft_fail'] - - context.obj['client'] = get_scan_cycode_client(client_id, secret, not context.obj['show_secret']) - context.obj['scan_type'] = scan_type - context.obj['sync'] = sync - context.obj['severity_threshold'] = severity_threshold - context.obj['monitor'] = monitor - context.obj['report'] = report - context.obj[SCA_SKIP_RESTORE_DEPENDENCIES_FLAG] = no_restore - context.obj[SCA_GRADLE_ALL_SUB_PROJECTS_FLAG] = gradle_all_sub_projects - - _sca_scan_to_context(context, sca_scan) - - return 1 - - -def _sca_scan_to_context(context: click.Context, sca_scan_user_selected: List[str]) -> None: - for sca_scan_option_selected in sca_scan_user_selected: - context.obj[sca_scan_option_selected] = True - - -@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() - - if context.obj['soft_fail']: - sys.exit(0) - - exit_code = NO_ISSUES_STATUS_CODE - if scan_utils.is_scan_failed(context): - exit_code = ISSUE_DETECTED_STATUS_CODE - - sys.exit(exit_code) diff --git a/cycode/cli/commands/status/__init__.py b/cycode/cli/commands/status/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/cycode/cli/commands/status/status_command.py b/cycode/cli/commands/status/status_command.py deleted file mode 100644 index f5d9aec3..00000000 --- a/cycode/cli/commands/status/status_command.py +++ /dev/null @@ -1,122 +0,0 @@ -import dataclasses -import json -import platform -from typing import Dict - -import click - -from cycode import __version__ -from cycode.cli.commands.auth_common import get_authorization_info -from cycode.cli.consts import PROGRAM_NAME -from cycode.cli.user_settings.configuration_manager import ConfigurationManager -from cycode.cli.utils.get_api_client import get_scan_cycode_client -from cycode.cyclient import logger - - -class CliStatusBase: - def as_dict(self) -> Dict[str, any]: - return dataclasses.asdict(self) - - def _get_text_message_part(self, key: str, value: any, intent: int = 0) -> str: - message_parts = [] - - intent_prefix = ' ' * intent * 2 - human_readable_key = key.replace('_', ' ').capitalize() - - if isinstance(value, dict): - message_parts.append(f'{intent_prefix}{human_readable_key}:') - for sub_key, sub_value in value.items(): - message_parts.append(self._get_text_message_part(sub_key, sub_value, intent=intent + 1)) - elif isinstance(value, (list, set, tuple)): - message_parts.append(f'{intent_prefix}{human_readable_key}:') - for index, sub_value in enumerate(value): - message_parts.append(self._get_text_message_part(f'#{index}', sub_value, intent=intent + 1)) - else: - message_parts.append(f'{intent_prefix}{human_readable_key}: {value}') - - return '\n'.join(message_parts) - - def as_text(self) -> str: - message_parts = [] - for key, value in self.as_dict().items(): - message_parts.append(self._get_text_message_part(key, value)) - - return '\n'.join(message_parts) - - def as_json(self) -> str: - return json.dumps(self.as_dict()) - - -@dataclasses.dataclass -class CliSupportedModulesStatus(CliStatusBase): - secret_scanning: bool = False - sca_scanning: bool = False - iac_scanning: bool = False - sast_scanning: bool = False - ai_large_language_model: bool = False - - -@dataclasses.dataclass -class CliStatus(CliStatusBase): - program: str - version: str - os: str - arch: str - python_version: str - installation_id: str - app_url: str - api_url: str - is_authenticated: bool - user_id: str = None - tenant_id: str = None - supported_modules: CliSupportedModulesStatus = None - - -def get_cli_status() -> CliStatus: - configuration_manager = ConfigurationManager() - - auth_info = get_authorization_info() - is_authenticated = auth_info is not None - - supported_modules_status = CliSupportedModulesStatus() - if is_authenticated: - try: - client = get_scan_cycode_client() - supported_modules_preferences = client.get_supported_modules_preferences() - - supported_modules_status.secret_scanning = supported_modules_preferences.secret_scanning - supported_modules_status.sca_scanning = supported_modules_preferences.sca_scanning - supported_modules_status.iac_scanning = supported_modules_preferences.iac_scanning - supported_modules_status.sast_scanning = supported_modules_preferences.sast_scanning - supported_modules_status.ai_large_language_model = supported_modules_preferences.ai_large_language_model - except Exception as e: - logger.debug('Failed to get supported modules preferences', exc_info=e) - - return CliStatus( - program=PROGRAM_NAME, - version=__version__, - os=platform.system(), - arch=platform.machine(), - python_version=platform.python_version(), - installation_id=configuration_manager.get_or_create_installation_id(), - app_url=configuration_manager.get_cycode_app_url(), - api_url=configuration_manager.get_cycode_api_url(), - is_authenticated=is_authenticated, - user_id=auth_info.user_id if auth_info else None, - tenant_id=auth_info.tenant_id if auth_info else None, - supported_modules=supported_modules_status, - ) - - -@click.command(short_help='Show the CLI status and exit.') -@click.pass_context -def status_command(context: click.Context) -> None: - output = context.obj['output'] - - status = get_cli_status() - message = status.as_text() - if output == 'json': - message = status.as_json() - - click.echo(message, color=context.color) - context.exit() diff --git a/cycode/cli/commands/version/__init__.py b/cycode/cli/commands/version/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/cycode/cli/commands/version/version_command.py b/cycode/cli/commands/version/version_command.py deleted file mode 100644 index 107aedbc..00000000 --- a/cycode/cli/commands/version/version_command.py +++ /dev/null @@ -1,22 +0,0 @@ -import json - -import click - -from cycode import __version__ -from cycode.cli.consts import PROGRAM_NAME - - -@click.command(short_help='Show the CLI version and exit. Use `cycode status` instead.', deprecated=True) -@click.pass_context -def version_command(context: click.Context) -> None: - output = context.obj['output'] - - prog = PROGRAM_NAME - ver = __version__ - - message = f'{prog}, version {ver}' - if output == 'json': - message = json.dumps({'name': prog, 'version': ver}) - - click.echo(message, color=context.color) - context.exit() diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 558f5b7b..1bca08db 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -1,20 +1,17 @@ PROGRAM_NAME = 'cycode' APP_NAME = 'CycodeCLI' -CLI_CONTEXT_SETTINGS = { - 'terminal_width': 10**9, - 'max_content_width': 10**9, -} +CLI_CONTEXT_SETTINGS = {'terminal_width': 10**9, 'max_content_width': 10**9, 'help_option_names': ['-h', '--help']} PRE_COMMIT_COMMAND_SCAN_TYPE = 'pre_commit' PRE_RECEIVE_COMMAND_SCAN_TYPE = 'pre_receive' COMMIT_HISTORY_COMMAND_SCAN_TYPE = 'commit_history' SECRET_SCAN_TYPE = 'secret' # noqa: S105 -INFRA_CONFIGURATION_SCAN_TYPE = 'iac' +IAC_SCAN_TYPE = 'iac' SCA_SCAN_TYPE = 'sca' SAST_SCAN_TYPE = 'sast' -INFRA_CONFIGURATION_SCAN_SUPPORTED_FILES = ('.tf', '.tf.json', '.json', '.yaml', '.yml', 'dockerfile') +IAC_SCAN_SUPPORTED_FILES = ('.tf', '.tf.json', '.json', '.yaml', '.yml', 'dockerfile') SECRET_SCAN_FILE_EXTENSIONS_TO_IGNORE = ( '.7z', @@ -145,7 +142,11 @@ # scan in batches DEFAULT_SCAN_BATCH_MAX_SIZE_IN_BYTES = 9 * 1024 * 1024 SCAN_BATCH_MAX_SIZE_IN_BYTES = {SAST_SCAN_TYPE: 50 * 1024 * 1024} +SCAN_BATCH_MAX_SIZE_IN_BYTES_ENV_VAR_NAME = 'SCAN_BATCH_MAX_SIZE_IN_BYTES' + DEFAULT_SCAN_BATCH_MAX_FILES_COUNT = 1000 +SCAN_BATCH_MAX_FILES_COUNT_ENV_VAR_NAME = 'SCAN_BATCH_MAX_FILES_COUNT' + # if we increase this values, the server doesn't allow connecting (ConnectionError) SCAN_BATCH_MAX_PARALLEL_SCANS = 5 SCAN_BATCH_SCANS_PER_CPU = 1 diff --git a/cycode/cli/exceptions/handle_ai_remediation_errors.py b/cycode/cli/exceptions/handle_ai_remediation_errors.py index ba46cbf7..961acd62 100644 --- a/cycode/cli/exceptions/handle_ai_remediation_errors.py +++ b/cycode/cli/exceptions/handle_ai_remediation_errors.py @@ -1,14 +1,14 @@ -import click +import typer -from cycode.cli.exceptions.common import handle_errors from cycode.cli.exceptions.custom_exceptions import KNOWN_USER_FRIENDLY_REQUEST_ERRORS, RequestHttpError +from cycode.cli.exceptions.handle_errors import handle_errors from cycode.cli.models import CliError, CliErrors class AiRemediationNotFoundError(Exception): ... -def handle_ai_remediation_exception(context: click.Context, err: Exception) -> None: +def handle_ai_remediation_exception(ctx: typer.Context, err: Exception) -> None: if isinstance(err, RequestHttpError) and err.status_code == 404: err = AiRemediationNotFoundError() @@ -19,4 +19,4 @@ def handle_ai_remediation_exception(context: click.Context, err: Exception) -> N message='The AI remediation was not found. Please try different detection ID', ), } - handle_errors(context, err, errors) + handle_errors(ctx, err, errors) diff --git a/cycode/cli/exceptions/handle_auth_errors.py b/cycode/cli/exceptions/handle_auth_errors.py new file mode 100644 index 00000000..72e18c88 --- /dev/null +++ b/cycode/cli/exceptions/handle_auth_errors.py @@ -0,0 +1,18 @@ +import typer + +from cycode.cli.exceptions.custom_exceptions import ( + KNOWN_USER_FRIENDLY_REQUEST_ERRORS, + AuthProcessError, +) +from cycode.cli.exceptions.handle_errors import handle_errors +from cycode.cli.models import CliError, CliErrors + + +def handle_auth_exception(ctx: typer.Context, err: Exception) -> None: + errors: CliErrors = { + **KNOWN_USER_FRIENDLY_REQUEST_ERRORS, + AuthProcessError: CliError( + code='auth_error', message='Authentication failed. Please try again later using the command `cycode auth`' + ), + } + handle_errors(ctx, err, errors) diff --git a/cycode/cli/exceptions/common.py b/cycode/cli/exceptions/handle_errors.py similarity index 61% rename from cycode/cli/exceptions/common.py rename to cycode/cli/exceptions/handle_errors.py index 51433af7..db102773 100644 --- a/cycode/cli/exceptions/common.py +++ b/cycode/cli/exceptions/handle_errors.py @@ -1,27 +1,28 @@ from typing import Optional import click +import typer from cycode.cli.models import CliError, CliErrors from cycode.cli.printers import ConsolePrinter -from cycode.cli.sentry import capture_exception +from cycode.cli.utils.sentry import capture_exception def handle_errors( - context: click.Context, err: BaseException, cli_errors: CliErrors, *, return_exception: bool = False + ctx: typer.Context, err: BaseException, cli_errors: CliErrors, *, return_exception: bool = False ) -> Optional['CliError']: - ConsolePrinter(context).print_exception(err) + ConsolePrinter(ctx).print_exception(err) if type(err) in cli_errors: error = cli_errors[type(err)] if error.soft_fail is True: - context.obj['soft_fail'] = True + ctx.obj['soft_fail'] = True if return_exception: return error - ConsolePrinter(context).print_error(error) + ConsolePrinter(ctx).print_error(error) return None if isinstance(err, click.ClickException): @@ -33,5 +34,5 @@ def handle_errors( if return_exception: return unknown_error - ConsolePrinter(context).print_error(unknown_error) - exit(1) + ConsolePrinter(ctx).print_error(unknown_error) + raise typer.Exit(1) diff --git a/cycode/cli/exceptions/handle_report_sbom_errors.py b/cycode/cli/exceptions/handle_report_sbom_errors.py index 70cf6277..22707c8c 100644 --- a/cycode/cli/exceptions/handle_report_sbom_errors.py +++ b/cycode/cli/exceptions/handle_report_sbom_errors.py @@ -1,12 +1,12 @@ -import click +import typer from cycode.cli.exceptions import custom_exceptions -from cycode.cli.exceptions.common import handle_errors from cycode.cli.exceptions.custom_exceptions import KNOWN_USER_FRIENDLY_REQUEST_ERRORS +from cycode.cli.exceptions.handle_errors import handle_errors from cycode.cli.models import CliError, CliErrors -def handle_report_exception(context: click.Context, err: Exception) -> None: +def handle_report_exception(ctx: typer.Context, err: Exception) -> None: errors: CliErrors = { **KNOWN_USER_FRIENDLY_REQUEST_ERRORS, custom_exceptions.ScanAsyncError: CliError( @@ -20,4 +20,4 @@ def handle_report_exception(context: click.Context, err: Exception) -> None: 'Please try again by executing the `cycode report` command', ), } - handle_errors(context, err, errors) + handle_errors(ctx, err, errors) diff --git a/cycode/cli/exceptions/handle_scan_errors.py b/cycode/cli/exceptions/handle_scan_errors.py index 550e6879..09890247 100644 --- a/cycode/cli/exceptions/handle_scan_errors.py +++ b/cycode/cli/exceptions/handle_scan_errors.py @@ -1,18 +1,16 @@ from typing import Optional -import click +import typer from cycode.cli.exceptions import custom_exceptions -from cycode.cli.exceptions.common import handle_errors from cycode.cli.exceptions.custom_exceptions import KNOWN_USER_FRIENDLY_REQUEST_ERRORS +from cycode.cli.exceptions.handle_errors import handle_errors from cycode.cli.models import CliError, CliErrors from cycode.cli.utils.git_proxy import git_proxy -def handle_scan_exception( - context: click.Context, err: Exception, *, return_exception: bool = False -) -> Optional[CliError]: - context.obj['did_fail'] = True +def handle_scan_exception(ctx: typer.Context, err: Exception, *, return_exception: bool = False) -> Optional[CliError]: + ctx.obj['did_fail'] = True errors: CliErrors = { **KNOWN_USER_FRIENDLY_REQUEST_ERRORS, @@ -45,4 +43,4 @@ def handle_scan_exception( ), } - return handle_errors(context, err, errors, return_exception=return_exception) + return handle_errors(ctx, err, errors, return_exception=return_exception) diff --git a/cycode/cli/files_collector/excluder.py b/cycode/cli/files_collector/excluder.py index b8cb7920..2d8243cb 100644 --- a/cycode/cli/files_collector/excluder.py +++ b/cycode/cli/files_collector/excluder.py @@ -149,8 +149,8 @@ def _is_relevant_document_to_scan(scan_type: str, filename: str, content: str) - def _is_file_extension_supported(scan_type: str, filename: str) -> bool: filename = filename.lower() - if scan_type == consts.INFRA_CONFIGURATION_SCAN_TYPE: - return filename.endswith(consts.INFRA_CONFIGURATION_SCAN_SUPPORTED_FILES) + if scan_type == consts.IAC_SCAN_TYPE: + return filename.endswith(consts.IAC_SCAN_SUPPORTED_FILES) if scan_type == consts.SCA_SCAN_TYPE: return filename.endswith(consts.SCA_CONFIGURATION_SCAN_SUPPORTED_FILES) diff --git a/cycode/cli/files_collector/iac/tf_content_generator.py b/cycode/cli/files_collector/iac/tf_content_generator.py index 57ebb4b1..8f4cb4d0 100644 --- a/cycode/cli/files_collector/iac/tf_content_generator.py +++ b/cycode/cli/files_collector/iac/tf_content_generator.py @@ -17,7 +17,7 @@ def generate_tfplan_document_name(path: str) -> str: def is_iac(scan_type: str) -> bool: - return scan_type == consts.INFRA_CONFIGURATION_SCAN_TYPE + return scan_type == consts.IAC_SCAN_TYPE def is_tfplan_file(file: str, content: str) -> bool: diff --git a/cycode/cli/files_collector/path_documents.py b/cycode/cli/files_collector/path_documents.py index 14f88888..571773a0 100644 --- a/cycode/cli/files_collector/path_documents.py +++ b/cycode/cli/files_collector/path_documents.py @@ -42,7 +42,7 @@ def _get_relevant_files_in_path(path: str) -> List[str]: def _get_relevant_files( - progress_bar: 'BaseProgressBar', progress_bar_section: 'ProgressBarSection', scan_type: str, paths: Tuple[str] + progress_bar: 'BaseProgressBar', progress_bar_section: 'ProgressBarSection', scan_type: str, paths: Tuple[str, ...] ) -> List[str]: all_files_to_scan = [] for path in paths: @@ -89,7 +89,7 @@ def get_relevant_documents( progress_bar: 'BaseProgressBar', progress_bar_section: 'ProgressBarSection', scan_type: str, - paths: Tuple[str], + paths: Tuple[str, ...], *, is_git_diff: bool = False, ) -> List[Document]: diff --git a/cycode/cli/files_collector/sca/base_restore_dependencies.py b/cycode/cli/files_collector/sca/base_restore_dependencies.py index 81caea1d..b1bf9e80 100644 --- a/cycode/cli/files_collector/sca/base_restore_dependencies.py +++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod from typing import List, Optional -import click +import typer from cycode.cli.models import Document from cycode.cli.utils.path_utils import get_file_content, get_file_dir, get_path_from_context, join_paths @@ -43,9 +43,9 @@ def execute_commands( class BaseRestoreDependencies(ABC): def __init__( - self, context: click.Context, is_git_diff: bool, command_timeout: int, create_output_file_manually: bool = False + self, ctx: typer.Context, is_git_diff: bool, command_timeout: int, create_output_file_manually: bool = False ) -> None: - self.context = context + self.ctx = ctx self.is_git_diff = is_git_diff self.command_timeout = command_timeout self.create_output_file_manually = create_output_file_manually @@ -55,9 +55,7 @@ def restore(self, document: Document) -> Optional[Document]: def get_manifest_file_path(self, document: Document) -> str: return ( - join_paths(get_path_from_context(self.context), document.path) - if self.context.obj.get('monitor') - else document.path + join_paths(get_path_from_context(self.ctx), document.path) if self.ctx.obj.get('monitor') else document.path ) def try_restore_dependencies(self, document: Document) -> Optional[Document]: diff --git a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py index 1986b3a2..77af2a57 100644 --- a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py +++ b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py @@ -2,7 +2,7 @@ import os from typing import List, Optional -import click +import typer from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies from cycode.cli.models import Document @@ -14,8 +14,8 @@ class RestoreGoDependencies(BaseRestoreDependencies): - def __init__(self, context: click.Context, is_git_diff: bool, command_timeout: int) -> None: - super().__init__(context, is_git_diff, command_timeout, create_output_file_manually=True) + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout, create_output_file_manually=True) def try_restore_dependencies(self, document: Document) -> Optional[Document]: manifest_exists = os.path.isfile(self.get_working_directory(document) + os.sep + BUILD_GO_FILE_NAME) diff --git a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py index 85dc9e20..a5c6d48b 100644 --- a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py @@ -2,7 +2,7 @@ import re from typing import List, Optional, Set -import click +import typer from cycode.cli.consts import SCA_GRADLE_ALL_SUB_PROJECTS_FLAG from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies @@ -20,15 +20,15 @@ class RestoreGradleDependencies(BaseRestoreDependencies): def __init__( - self, context: click.Context, is_git_diff: bool, command_timeout: int, projects: Optional[Set[str]] = None + self, ctx: typer.Context, is_git_diff: bool, command_timeout: int, projects: Optional[Set[str]] = None ) -> None: - super().__init__(context, is_git_diff, command_timeout, create_output_file_manually=True) + super().__init__(ctx, is_git_diff, command_timeout, create_output_file_manually=True) if projects is None: projects = set() self.projects = self.get_all_projects() if self.is_gradle_sub_projects() else projects def is_gradle_sub_projects(self) -> bool: - return self.context.obj.get(SCA_GRADLE_ALL_SUB_PROJECTS_FLAG) + return self.ctx.obj.get(SCA_GRADLE_ALL_SUB_PROJECTS_FLAG) def is_project(self, document: Document) -> bool: return document.path.endswith(BUILD_GRADLE_FILE_NAME) or document.path.endswith(BUILD_GRADLE_KTS_FILE_NAME) @@ -47,13 +47,13 @@ def verify_restore_file_already_exist(self, restore_file_path: str) -> bool: return os.path.isfile(restore_file_path) def get_working_directory(self, document: Document) -> Optional[str]: - return get_path_from_context(self.context) if self.is_gradle_sub_projects() else None + return get_path_from_context(self.ctx) if self.is_gradle_sub_projects() else None def get_all_projects(self) -> Set[str]: projects_output = shell( command=BUILD_GRADLE_ALL_PROJECTS_COMMAND, timeout=BUILD_GRADLE_ALL_PROJECTS_TIMEOUT, - working_directory=get_path_from_context(self.context), + working_directory=get_path_from_context(self.ctx), ) projects = re.findall(ALL_PROJECTS_REGEX, projects_output) diff --git a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py index a44a27e0..d90bbe71 100644 --- a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py @@ -2,7 +2,7 @@ from os import path from typing import List, Optional -import click +import typer from cycode.cli.files_collector.sca.base_restore_dependencies import ( BaseRestoreDependencies, @@ -18,8 +18,8 @@ class RestoreMavenDependencies(BaseRestoreDependencies): - def __init__(self, context: click.Context, is_git_diff: bool, command_timeout: int) -> None: - super().__init__(context, is_git_diff, command_timeout) + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) def is_project(self, document: Document) -> bool: return path.basename(document.path).split('/')[-1] == BUILD_MAVEN_FILE_NAME diff --git a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py index c3026938..68175d88 100644 --- a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py +++ b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py @@ -1,7 +1,7 @@ import os from typing import List -import click +import typer from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies from cycode.cli.models import Document @@ -12,8 +12,8 @@ class RestoreNpmDependencies(BaseRestoreDependencies): - def __init__(self, context: click.Context, is_git_diff: bool, command_timeout: int) -> None: - super().__init__(context, is_git_diff, command_timeout) + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) def is_project(self, document: Document) -> bool: return any(document.path.endswith(ext) for ext in NPM_PROJECT_FILE_EXTENSIONS) diff --git a/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py b/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py index 0e2ed83d..b4f5a248 100644 --- a/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py +++ b/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py @@ -1,7 +1,7 @@ import os from typing import List -import click +import typer from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies from cycode.cli.models import Document @@ -11,8 +11,8 @@ class RestoreNugetDependencies(BaseRestoreDependencies): - def __init__(self, context: click.Context, is_git_diff: bool, command_timeout: int) -> None: - super().__init__(context, is_git_diff, command_timeout) + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) def is_project(self, document: Document) -> bool: return any(document.path.endswith(ext) for ext in NUGET_PROJECT_FILE_EXTENSIONS) diff --git a/cycode/cli/files_collector/sca/sca_code_scanner.py b/cycode/cli/files_collector/sca/sca_code_scanner.py index ca6908b6..3f54eb12 100644 --- a/cycode/cli/files_collector/sca/sca_code_scanner.py +++ b/cycode/cli/files_collector/sca/sca_code_scanner.py @@ -1,7 +1,7 @@ import os from typing import TYPE_CHECKING, Dict, List, Optional -import click +import typer from cycode.cli import consts from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies @@ -89,7 +89,7 @@ def get_project_file_ecosystem(document: Document) -> Optional[str]: def try_restore_dependencies( - context: click.Context, + ctx: typer.Context, documents_to_add: Dict[str, Document], restore_dependencies: 'BaseRestoreDependencies', document: Document, @@ -106,8 +106,8 @@ def try_restore_dependencies( logger.warning('Error occurred while trying to generate dependencies tree, %s', {'filename': document.path}) restore_dependencies_document.content = '' else: - is_monitor_action = context.obj.get('monitor', False) - project_path = get_path_from_context(context) + is_monitor_action = ctx.obj.get('monitor', False) + project_path = get_path_from_context(ctx) manifest_file_path = get_manifest_file_path(document, is_monitor_action, project_path) logger.debug('Succeeded to generate dependencies tree on path: %s', manifest_file_path) @@ -119,27 +119,27 @@ def try_restore_dependencies( def add_dependencies_tree_document( - context: click.Context, documents_to_scan: List[Document], is_git_diff: bool = False + ctx: typer.Context, documents_to_scan: List[Document], is_git_diff: bool = False ) -> None: documents_to_add: Dict[str, Document] = {} - restore_dependencies_list = restore_handlers(context, is_git_diff) + restore_dependencies_list = restore_handlers(ctx, is_git_diff) for restore_dependencies in restore_dependencies_list: for document in documents_to_scan: - try_restore_dependencies(context, documents_to_add, restore_dependencies, document) + try_restore_dependencies(ctx, documents_to_add, restore_dependencies, document) documents_to_scan.extend(list(documents_to_add.values())) -def restore_handlers(context: click.Context, is_git_diff: bool) -> List[BaseRestoreDependencies]: +def restore_handlers(ctx: typer.Context, is_git_diff: bool) -> List[BaseRestoreDependencies]: return [ - RestoreGradleDependencies(context, is_git_diff, BUILD_DEP_TREE_TIMEOUT), - RestoreMavenDependencies(context, is_git_diff, BUILD_DEP_TREE_TIMEOUT), - RestoreSbtDependencies(context, is_git_diff, BUILD_DEP_TREE_TIMEOUT), - RestoreGoDependencies(context, is_git_diff, BUILD_DEP_TREE_TIMEOUT), - RestoreNugetDependencies(context, is_git_diff, BUILD_DEP_TREE_TIMEOUT), - RestoreNpmDependencies(context, is_git_diff, BUILD_DEP_TREE_TIMEOUT), - RestoreRubyDependencies(context, is_git_diff, BUILD_DEP_TREE_TIMEOUT), + RestoreGradleDependencies(ctx, is_git_diff, BUILD_DEP_TREE_TIMEOUT), + RestoreMavenDependencies(ctx, is_git_diff, BUILD_DEP_TREE_TIMEOUT), + RestoreSbtDependencies(ctx, is_git_diff, BUILD_DEP_TREE_TIMEOUT), + RestoreGoDependencies(ctx, is_git_diff, BUILD_DEP_TREE_TIMEOUT), + RestoreNugetDependencies(ctx, is_git_diff, BUILD_DEP_TREE_TIMEOUT), + RestoreNpmDependencies(ctx, is_git_diff, BUILD_DEP_TREE_TIMEOUT), + RestoreRubyDependencies(ctx, is_git_diff, BUILD_DEP_TREE_TIMEOUT), ] @@ -155,8 +155,8 @@ def get_file_content_from_commit(repo: 'Repo', commit: str, file_path: str) -> O def perform_pre_scan_documents_actions( - context: click.Context, scan_type: str, documents_to_scan: List[Document], is_git_diff: bool = False + ctx: typer.Context, scan_type: str, documents_to_scan: List[Document], is_git_diff: bool = False ) -> None: - if scan_type == consts.SCA_SCAN_TYPE and not context.obj.get(consts.SCA_SKIP_RESTORE_DEPENDENCIES_FLAG): + if scan_type == consts.SCA_SCAN_TYPE and not ctx.obj.get(consts.SCA_SKIP_RESTORE_DEPENDENCIES_FLAG): logger.debug('Perform pre-scan document add_dependencies_tree_document action') - add_dependencies_tree_document(context, documents_to_scan, is_git_diff) + add_dependencies_tree_document(ctx, documents_to_scan, is_git_diff) diff --git a/cycode/cli/main.py b/cycode/cli/main.py index dd2d1fa7..c6a857a4 100644 --- a/cycode/cli/main.py +++ b/cycode/cli/main.py @@ -1,11 +1,10 @@ from multiprocessing import freeze_support -from cycode.cli.commands.main_cli import main_cli +from cycode.cli.app import app -if __name__ == '__main__': - # DO NOT REMOVE OR MOVE THIS LINE - # this is required to support multiprocessing in executables files packaged with PyInstaller - # see https://pyinstaller.org/en/latest/common-issues-and-pitfalls.html#multi-processing - freeze_support() +# DO NOT REMOVE OR MOVE THIS LINE +# this is required to support multiprocessing in executables files packaged with PyInstaller +# see https://pyinstaller.org/en/latest/common-issues-and-pitfalls.html#multi-processing +freeze_support() - main_cli() +app() diff --git a/cycode/cli/models.py b/cycode/cli/models.py index 25b2347f..df62583a 100644 --- a/cycode/cli/models.py +++ b/cycode/cli/models.py @@ -1,5 +1,4 @@ from dataclasses import dataclass -from enum import Enum from typing import Dict, List, NamedTuple, Optional, Type from cycode.cyclient.models import Detection @@ -33,34 +32,6 @@ def __repr__(self) -> str: return 'document:{0}, detections:{1}'.format(self.document, self.detections) -SEVERITY_UNKNOWN_WEIGHT = -2 - - -class Severity(Enum): - INFO = -1 - LOW = 0 - MEDIUM = 1 - MODERATE = 1 # noqa: PIE796. TODO(MarshalX): rework. should not be Enum - HIGH = 2 - CRITICAL = 3 - - @staticmethod - def try_get_value(name: str) -> Optional[int]: - name = name.upper() - if name not in Severity.__members__: - return None - - return Severity[name].value - - @staticmethod - def get_member_weight(name: str) -> int: - weight = Severity.try_get_value(name) - if weight is None: # unknown severity - return SEVERITY_UNKNOWN_WEIGHT - - return weight - - class CliError(NamedTuple): code: str message: str diff --git a/cycode/cli/printers/console_printer.py b/cycode/cli/printers/console_printer.py index 1f70836c..64efa9d5 100644 --- a/cycode/cli/printers/console_printer.py +++ b/cycode/cli/printers/console_printer.py @@ -1,6 +1,6 @@ from typing import TYPE_CHECKING, ClassVar, Dict, List, Optional, Type -import click +import typer from cycode.cli.exceptions.custom_exceptions import CycodeError from cycode.cli.models import CliError, CliResult @@ -24,11 +24,13 @@ class ConsolePrinter: 'text_sca': ScaTablePrinter, } - def __init__(self, context: click.Context) -> None: - self.context = context - self.scan_type = self.context.obj.get('scan_type') - self.output_type = self.context.obj.get('output') - self.aggregation_report_url = self.context.obj.get('aggregation_report_url') + def __init__(self, ctx: typer.Context) -> None: + self.ctx = ctx + + self.scan_type = self.ctx.obj.get('scan_type') + self.output_type = self.ctx.obj.get('output') + self.aggregation_report_url = self.ctx.obj.get('aggregation_report_url') + self._printer_class = self._AVAILABLE_PRINTERS.get(self.output_type) if self._printer_class is None: raise CycodeError(f'"{self.output_type}" output type is not supported.') @@ -48,18 +50,18 @@ def _get_scan_printer(self) -> 'PrinterBase': if composite_printer: printer_class = composite_printer - return printer_class(self.context) + return printer_class(self.ctx) def print_result(self, result: CliResult) -> None: - self._printer_class(self.context).print_result(result) + self._printer_class(self.ctx).print_result(result) def print_error(self, error: CliError) -> None: - self._printer_class(self.context).print_error(error) + self._printer_class(self.ctx).print_error(error) def print_exception(self, e: Optional[BaseException] = None, force_print: bool = False) -> None: """Print traceback message in stderr if verbose mode is set.""" - if force_print or self.context.obj.get('verbose', False): - self._printer_class(self.context).print_exception(e) + if force_print or self.ctx.obj.get('verbose', False): + self._printer_class(self.ctx).print_exception(e) @property def is_json_printer(self) -> bool: diff --git a/cycode/cli/printers/json_printer.py b/cycode/cli/printers/json_printer.py index b682b8c7..c8fbacb3 100644 --- a/cycode/cli/printers/json_printer.py +++ b/cycode/cli/printers/json_printer.py @@ -28,7 +28,7 @@ def print_scan_results( scan_ids = [] report_urls = [] detections = [] - aggregation_report_url = self.context.obj.get('aggregation_report_url') + aggregation_report_url = self.ctx.obj.get('aggregation_report_url') if aggregation_report_url: report_urls.append(aggregation_report_url) diff --git a/cycode/cli/printers/printer_base.py b/cycode/cli/printers/printer_base.py index fa5bf435..45419aec 100644 --- a/cycode/cli/printers/printer_base.py +++ b/cycode/cli/printers/printer_base.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Dict, List, Optional import click +import typer from cycode.cli.models import CliError, CliResult from cycode.cyclient.headers import get_correlation_id @@ -16,8 +17,8 @@ class PrinterBase(ABC): WHITE_COLOR_NAME = 'white' GREEN_COLOR_NAME = 'green' - def __init__(self, context: click.Context) -> None: - self.context = context + def __init__(self, ctx: typer.Context) -> None: + self.ctx = ctx @abstractmethod def print_scan_results( diff --git a/cycode/cli/printers/tables/sca_table_printer.py b/cycode/cli/printers/tables/sca_table_printer.py index 5a6ec726..063e80e8 100644 --- a/cycode/cli/printers/tables/sca_table_printer.py +++ b/cycode/cli/printers/tables/sca_table_printer.py @@ -3,8 +3,9 @@ import click +from cycode.cli.cli_types import SeverityOption from cycode.cli.consts import LICENSE_COMPLIANCE_POLICY_ID, PACKAGE_VULNERABILITY_POLICY_ID -from cycode.cli.models import SEVERITY_UNKNOWN_WEIGHT, Detection, Severity +from cycode.cli.models import Detection from cycode.cli.printers.tables.table import Table from cycode.cli.printers.tables.table_models import ColumnInfoBuilder, ColumnWidths from cycode.cli.printers.tables.table_printer_base import TablePrinterBase @@ -40,7 +41,7 @@ class ScaTablePrinter(TablePrinterBase): def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: - aggregation_report_url = self.context.obj.get('aggregation_report_url') + aggregation_report_url = self.ctx.obj.get('aggregation_report_url') detections_per_policy_id = self._extract_detections_per_policy_id(local_scan_results) for policy_id, detections in detections_per_policy_id.items(): table = self._get_table(policy_id) @@ -72,11 +73,8 @@ def __group_by(detections: List[Detection], details_field_name: str) -> Dict[str @staticmethod def __severity_sort_key(detection: Detection) -> int: - severity = detection.detection_details.get('advisory_severity') - if severity: - return Severity.get_member_weight(severity) - - return SEVERITY_UNKNOWN_WEIGHT + severity = detection.detection_details.get('advisory_severity', 'unknown') + return SeverityOption.get_member_weight(severity) def _sort_detections_by_severity(self, detections: List[Detection]) -> List[Detection]: return sorted(detections, key=self.__severity_sort_key, reverse=True) diff --git a/cycode/cli/printers/tables/table_printer.py b/cycode/cli/printers/tables/table_printer.py index f2153e56..61234066 100644 --- a/cycode/cli/printers/tables/table_printer.py +++ b/cycode/cli/printers/tables/table_printer.py @@ -2,7 +2,7 @@ import click -from cycode.cli.consts import INFRA_CONFIGURATION_SCAN_TYPE, SAST_SCAN_TYPE, SECRET_SCAN_TYPE +from cycode.cli.consts import IAC_SCAN_TYPE, SAST_SCAN_TYPE, SECRET_SCAN_TYPE from cycode.cli.models import Detection, Document from cycode.cli.printers.tables.table import Table from cycode.cli.printers.tables.table_models import ColumnInfoBuilder, ColumnWidthsConfig @@ -35,7 +35,7 @@ VIOLATION_COLUMN: 2, SCAN_ID_COLUMN: 2, }, - INFRA_CONFIGURATION_SCAN_TYPE: { + IAC_SCAN_TYPE: { ISSUE_TYPE_COLUMN: 4, RULE_ID_COLUMN: 3, FILE_PATH_COLUMN: 3, @@ -63,7 +63,7 @@ def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: self._enrich_table_with_values(table, detection, document_detections.document) self._print_table(table) - self._print_report_urls(local_scan_results, self.context.obj.get('aggregation_report_url')) + self._print_report_urls(local_scan_results, self.ctx.obj.get('aggregation_report_url')) def _get_table(self) -> Table: table = Table() diff --git a/cycode/cli/printers/tables/table_printer_base.py b/cycode/cli/printers/tables/table_printer_base.py index be41454f..abbc8251 100644 --- a/cycode/cli/printers/tables/table_printer_base.py +++ b/cycode/cli/printers/tables/table_printer_base.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Dict, List, Optional import click +import typer from cycode.cli.models import CliError, CliResult from cycode.cli.printers.printer_base import PrinterBase @@ -13,16 +14,16 @@ class TablePrinterBase(PrinterBase, abc.ABC): - def __init__(self, context: click.Context) -> None: - super().__init__(context) - self.scan_type: str = context.obj.get('scan_type') - self.show_secret: bool = context.obj.get('show_secret', False) + def __init__(self, ctx: typer.Context) -> None: + super().__init__(ctx) + self.scan_type: str = ctx.obj.get('scan_type') + self.show_secret: bool = ctx.obj.get('show_secret', False) def print_result(self, result: CliResult) -> None: - TextPrinter(self.context).print_result(result) + TextPrinter(self.ctx).print_result(result) def print_error(self, error: CliError) -> None: - TextPrinter(self.context).print_error(error) + TextPrinter(self.ctx).print_error(error) def print_scan_results( self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None @@ -46,7 +47,7 @@ def print_scan_results( self.print_error(error) def _is_git_repository(self) -> bool: - return self.context.obj.get('remote_url') is not None + return self.ctx.obj.get('remote_url') is not None @abc.abstractmethod def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: diff --git a/cycode/cli/printers/text_printer.py b/cycode/cli/printers/text_printer.py index 0b503207..0f617f8c 100644 --- a/cycode/cli/printers/text_printer.py +++ b/cycode/cli/printers/text_printer.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Dict, List, Optional import click +import typer from cycode.cli.config import config from cycode.cli.consts import COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES, SECRET_SCAN_TYPE @@ -14,11 +15,11 @@ class TextPrinter(PrinterBase): - def __init__(self, context: click.Context) -> None: - super().__init__(context) - self.scan_type: str = context.obj.get('scan_type') - self.command_scan_type: str = context.info_name - self.show_secret: bool = context.obj.get('show_secret', False) + def __init__(self, ctx: typer.Context) -> None: + super().__init__(ctx) + self.scan_type: str = ctx.obj.get('scan_type') + self.command_scan_type: str = ctx.info_name + self.show_secret: bool = ctx.obj.get('show_secret', False) def print_result(self, result: CliResult) -> None: color = None @@ -50,7 +51,7 @@ def print_scan_results( report_urls = [scan_result.report_url for scan_result in local_scan_results if scan_result.report_url] - self._print_report_urls(report_urls, self.context.obj.get('aggregation_report_url')) + self._print_report_urls(report_urls, self.ctx.obj.get('aggregation_report_url')) if not errors: return diff --git a/cycode/cli/user_settings/credentials_manager.py b/cycode/cli/user_settings/credentials_manager.py index ad380e8a..86a84ba6 100644 --- a/cycode/cli/user_settings/credentials_manager.py +++ b/cycode/cli/user_settings/credentials_manager.py @@ -3,9 +3,9 @@ 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 +from cycode.cli.utils.sentry import setup_scope_from_access_token class CredentialsManager(BaseFileManager): diff --git a/cycode/cli/utils/path_utils.py b/cycode/cli/utils/path_utils.py index a2d8816b..c6d2001d 100644 --- a/cycode/cli/utils/path_utils.py +++ b/cycode/cli/utils/path_utils.py @@ -3,7 +3,7 @@ from functools import lru_cache from typing import TYPE_CHECKING, AnyStr, List, Optional, Union -import click +import typer from binaryornot.helpers import is_binary_string from cycode.cyclient import logger @@ -106,8 +106,8 @@ def concat_unique_id(filename: str, unique_id: str) -> str: return os.path.join(unique_id, filename) -def get_path_from_context(context: click.Context) -> Optional[str]: - path = context.params.get('path') - if path is None and 'paths' in context.params: - path = context.params['paths'][0] +def get_path_from_context(ctx: typer.Context) -> Optional[str]: + path = ctx.params.get('path') + if path is None and 'paths' in ctx.params: + path = ctx.params['paths'][0] return path diff --git a/cycode/cli/utils/scan_batch.py b/cycode/cli/utils/scan_batch.py index 3d2d83dc..4019b7b0 100644 --- a/cycode/cli/utils/scan_batch.py +++ b/cycode/cli/utils/scan_batch.py @@ -5,17 +5,53 @@ from cycode.cli import consts from cycode.cli.models import Document from cycode.cli.utils.progress_bar import ScanProgressBarSection +from cycode.cyclient import logger if TYPE_CHECKING: from cycode.cli.models import CliError, LocalScanResult from cycode.cli.utils.progress_bar import BaseProgressBar +def _get_max_batch_size(scan_type: str) -> int: + logger.debug( + 'You can customize the batch size by setting the environment variable "%s"', + consts.SCAN_BATCH_MAX_SIZE_IN_BYTES_ENV_VAR_NAME, + ) + + custom_size = os.environ.get(consts.SCAN_BATCH_MAX_SIZE_IN_BYTES_ENV_VAR_NAME) + if custom_size: + logger.debug('Custom batch size is set, %s', {'custom_size': custom_size}) + return int(custom_size) + + return consts.SCAN_BATCH_MAX_SIZE_IN_BYTES.get(scan_type, consts.DEFAULT_SCAN_BATCH_MAX_SIZE_IN_BYTES) + + +def _get_max_batch_files_count(_: str) -> int: + logger.debug( + 'You can customize the batch files count by setting the environment variable "%s"', + consts.SCAN_BATCH_MAX_FILES_COUNT_ENV_VAR_NAME, + ) + + custom_files_count = os.environ.get(consts.SCAN_BATCH_MAX_FILES_COUNT_ENV_VAR_NAME) + if custom_files_count: + logger.debug('Custom batch files count is set, %s', {'custom_files_count': custom_files_count}) + return int(custom_files_count) + + return consts.DEFAULT_SCAN_BATCH_MAX_FILES_COUNT + + def split_documents_into_batches( + scan_type: str, documents: List[Document], - max_size: int = consts.DEFAULT_SCAN_BATCH_MAX_SIZE_IN_BYTES, - max_files_count: int = consts.DEFAULT_SCAN_BATCH_MAX_FILES_COUNT, ) -> List[List[Document]]: + max_size = _get_max_batch_size(scan_type) + max_files_count = _get_max_batch_files_count(scan_type) + + logger.debug( + 'Splitting documents into batches, %s', + {'document_count': len(documents), 'max_batch_size': max_size, 'max_files_count': max_files_count}, + ) + batches = [] current_size = 0 @@ -23,7 +59,29 @@ def split_documents_into_batches( for document in documents: document_size = len(document.content.encode('UTF-8')) - if (current_size + document_size > max_size) or (len(current_batch) >= max_files_count): + exceeds_max_size = current_size + document_size > max_size + if exceeds_max_size: + logger.debug( + 'Going to create new batch because current batch size exceeds the limit, %s', + { + 'batch_index': len(batches), + 'current_batch_size': current_size + document_size, + 'max_batch_size': max_size, + }, + ) + + exceeds_max_files_count = len(current_batch) >= max_files_count + if exceeds_max_files_count: + logger.debug( + 'Going to create new batch because current batch files count exceeds the limit, %s', + { + 'batch_index': len(batches), + 'current_batch_files_count': len(current_batch), + 'max_batch_files_count': max_files_count, + }, + ) + + if exceeds_max_size or exceeds_max_files_count: batches.append(current_batch) current_batch = [document] @@ -35,6 +93,8 @@ def split_documents_into_batches( if current_batch: batches.append(current_batch) + logger.debug('Documents were split into batches %s', {'batches_count': len(batches)}) + return batches @@ -49,9 +109,8 @@ def run_parallel_batched_scan( documents: List[Document], progress_bar: 'BaseProgressBar', ) -> Tuple[Dict[str, 'CliError'], List['LocalScanResult']]: - max_size = consts.SCAN_BATCH_MAX_SIZE_IN_BYTES.get(scan_type, consts.DEFAULT_SCAN_BATCH_MAX_SIZE_IN_BYTES) - - batches = [documents] if scan_type == consts.SCA_SCAN_TYPE else split_documents_into_batches(documents, max_size) + # batching is disabled for SCA; requested by Mor + batches = [documents] if scan_type == consts.SCA_SCAN_TYPE else split_documents_into_batches(scan_type, documents) progress_bar.set_section_length(ScanProgressBarSection.SCAN, len(batches)) # * 3 # TODO(MarshalX): we should multiply the count of batches in SCAN section because each batch has 3 steps: @@ -61,9 +120,13 @@ def run_parallel_batched_scan( # it's not possible yet because not all scan types moved to polling mechanism # the progress bar could be significant improved (be more dynamic) in the future + threads_count = _get_threads_count() local_scan_results: List['LocalScanResult'] = [] cli_errors: Dict[str, 'CliError'] = {} - with ThreadPool(processes=_get_threads_count()) as pool: + + logger.debug('Running parallel batched scan, %s', {'threads_count': threads_count, 'batches_count': len(batches)}) + + with ThreadPool(processes=threads_count) as pool: for scan_id, err, result in pool.imap(scan_function, batches): if result: local_scan_results.append(result) diff --git a/cycode/cli/utils/scan_utils.py b/cycode/cli/utils/scan_utils.py index 77866c4b..8c9dcca7 100644 --- a/cycode/cli/utils/scan_utils.py +++ b/cycode/cli/utils/scan_utils.py @@ -1,11 +1,11 @@ -import click +import typer -def set_issue_detected(context: click.Context, issue_detected: bool) -> None: - context.obj['issue_detected'] = issue_detected +def set_issue_detected(ctx: typer.Context, issue_detected: bool) -> None: + ctx.obj['issue_detected'] = issue_detected -def is_scan_failed(context: click.Context) -> bool: - did_fail = context.obj.get('did_fail') - issue_detected = context.obj.get('issue_detected') +def is_scan_failed(ctx: typer.Context) -> bool: + did_fail = ctx.obj.get('did_fail') + issue_detected = ctx.obj.get('issue_detected') return did_fail or issue_detected diff --git a/cycode/cli/sentry.py b/cycode/cli/utils/sentry.py similarity index 100% rename from cycode/cli/sentry.py rename to cycode/cli/utils/sentry.py diff --git a/cycode/cli/commands/version/version_checker.py b/cycode/cli/utils/version_checker.py similarity index 95% rename from cycode/cli/commands/version/version_checker.py rename to cycode/cli/utils/version_checker.py index c5ec9d4f..40022cbd 100644 --- a/cycode/cli/commands/version/version_checker.py +++ b/cycode/cli/utils/version_checker.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import List, Optional, Tuple -import click +import typer from cycode.cli.user_settings.configuration_manager import ConfigurationManager from cycode.cli.utils.path_utils import get_file_content @@ -56,6 +56,7 @@ def _compare_versions( class VersionChecker(CycodeClientBase): PYPI_API_URL = 'https://pypi.org/pypi' PYPI_PACKAGE_NAME = 'cycode' + PYPI_REQUEST_TIMEOUT = 1 GIT_CHANGELOG_URL_PREFIX = 'https://github.com/cycodehq/cycode-cli/releases/tag/v' @@ -84,7 +85,7 @@ def get_latest_version(self) -> Optional[str]: or the version information is not available. """ try: - response = self.get(f'{self.PYPI_PACKAGE_NAME}/json') + response = self.get(f'{self.PYPI_PACKAGE_NAME}/json', timeout=self.PYPI_REQUEST_TIMEOUT) data = response.json() return data.get('info', {}).get('version') except Exception: @@ -199,11 +200,11 @@ def check_and_notify_update(self, current_version: str, use_color: bool = True, if should_update: update_message = ( '\nNew version of cycode available! ' - f"{click.style(current_version, fg='yellow')} → {click.style(latest_version, fg='bright_blue')}\n" - f"Changelog: {click.style(f'{self.GIT_CHANGELOG_URL_PREFIX}{latest_version}', fg='bright_blue')}\n" - f"Run {click.style('pip install --upgrade cycode', fg='green')} to update\n" + f"{typer.style(current_version, fg='yellow')} → {typer.style(latest_version, fg='bright_blue')}\n" + f"Changelog: {typer.style(f'{self.GIT_CHANGELOG_URL_PREFIX}{latest_version}', fg='bright_blue')}\n" + f"Run {typer.style('pip install --upgrade cycode', fg='green')} to update\n" ) - click.echo(update_message, color=use_color) + typer.echo(update_message, color=use_color) version_checker = VersionChecker() diff --git a/cycode/cyclient/headers.py b/cycode/cyclient/headers.py index c6983d32..4e2434e9 100644 --- a/cycode/cyclient/headers.py +++ b/cycode/cyclient/headers.py @@ -4,8 +4,8 @@ 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.cli.utils.sentry import add_correlation_id_to_scope from cycode.cyclient import logger diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index 31abba17..c6bfc57c 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -1,5 +1,6 @@ import json from typing import TYPE_CHECKING, List, Optional, Set, Union +from uuid import UUID from requests import Response @@ -32,7 +33,7 @@ def __init__( self._hide_response_log = hide_response_log def get_scan_controller_path(self, scan_type: str, should_use_scan_service: bool = False) -> str: - if not should_use_scan_service and scan_type == consts.INFRA_CONFIGURATION_SCAN_TYPE: + if not should_use_scan_service and scan_type == consts.IAC_SCAN_TYPE: # we don't use async flow for IaC scan yet return self._SCAN_SERVICE_CONTROLLER_PATH if not should_use_scan_service and scan_type == consts.SECRET_SCAN_TYPE: @@ -43,7 +44,7 @@ def get_scan_controller_path(self, scan_type: str, should_use_scan_service: bool return self._SCAN_SERVICE_CLI_CONTROLLER_PATH def get_detections_service_controller_path(self, scan_type: str) -> str: - if scan_type == consts.INFRA_CONFIGURATION_SCAN_TYPE: + if scan_type == consts.IAC_SCAN_TYPE: # we don't use async flow for IaC scan yet return self._DETECTIONS_SERVICE_CONTROLLER_PATH @@ -210,8 +211,8 @@ def get_supported_modules_preferences(self) -> models.SupportedModulesPreference def get_ai_remediation_path(detection_id: str) -> str: return f'scm-remediator/api/v1/ContentRemediation/preview/{detection_id}' - def get_ai_remediation(self, detection_id: str, *, fix: bool = False) -> str: - path = self.get_ai_remediation_path(detection_id) + def get_ai_remediation(self, detection_id: UUID, *, fix: bool = False) -> str: + path = self.get_ai_remediation_path(detection_id.hex) data = { 'resolving_parameters': { @@ -231,7 +232,7 @@ def get_ai_remediation(self, detection_id: str, *, fix: bool = False) -> str: @staticmethod def _get_policy_type_by_scan_type(scan_type: str) -> str: scan_type_to_policy_type = { - consts.INFRA_CONFIGURATION_SCAN_TYPE: 'IaC', + consts.IAC_SCAN_TYPE: 'IaC', consts.SCA_SCAN_TYPE: 'SCA', consts.SECRET_SCAN_TYPE: 'SecretDetection', consts.SAST_SCAN_TYPE: 'SAST', @@ -261,7 +262,7 @@ def get_scan_detections_path(self, scan_type: str) -> str: @staticmethod def get_scan_detections_list_path_suffix(scan_type: str) -> str: # we don't use async flow for IaC scan yet - if scan_type == consts.INFRA_CONFIGURATION_SCAN_TYPE: + if scan_type == consts.IAC_SCAN_TYPE: return '' return '/detections' @@ -330,7 +331,7 @@ def get_service_name(scan_type: str) -> Optional[str]: # TODO(MarshalX): get_service_name should be removed from ScanClient? Because it exists in ScanConfig if scan_type == consts.SECRET_SCAN_TYPE: return 'secret' - if scan_type == consts.INFRA_CONFIGURATION_SCAN_TYPE: + if scan_type == consts.IAC_SCAN_TYPE: return 'iac' if scan_type == consts.SCA_SCAN_TYPE or scan_type == consts.SAST_SCAN_TYPE: return 'scans' diff --git a/cycode/cyclient/scan_config_base.py b/cycode/cyclient/scan_config_base.py index 1ff1da6c..6dfa97ef 100644 --- a/cycode/cyclient/scan_config_base.py +++ b/cycode/cyclient/scan_config_base.py @@ -11,7 +11,7 @@ def get_service_name(self, scan_type: str, should_use_scan_service: bool = False def get_async_scan_type(scan_type: str) -> str: if scan_type == consts.SECRET_SCAN_TYPE: return 'Secrets' - if scan_type == consts.INFRA_CONFIGURATION_SCAN_TYPE: + if scan_type == consts.IAC_SCAN_TYPE: return 'InfraConfiguration' return scan_type.upper() @@ -33,7 +33,7 @@ def get_service_name(self, scan_type: str, should_use_scan_service: bool = False return '5004' if scan_type == consts.SECRET_SCAN_TYPE: return '5025' - if scan_type == consts.INFRA_CONFIGURATION_SCAN_TYPE: + if scan_type == consts.IAC_SCAN_TYPE: return '5026' # sca and sast @@ -49,7 +49,7 @@ def get_service_name(self, scan_type: str, should_use_scan_service: bool = False return 'scans' if scan_type == consts.SECRET_SCAN_TYPE: return 'secret' - if scan_type == consts.INFRA_CONFIGURATION_SCAN_TYPE: + if scan_type == consts.IAC_SCAN_TYPE: return 'iac' # sca and sast diff --git a/poetry.lock b/poetry.lock index c97b44a9..f104cc28 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.0 and should not be changed by hand. [[package]] name = "altgraph" @@ -6,6 +6,8 @@ version = "0.17.4" description = "Python graph (network) package" optional = false python-versions = "*" +groups = ["executable"] +markers = "python_version < \"3.13\"" files = [ {file = "altgraph-0.17.4-py2.py3-none-any.whl", hash = "sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff"}, {file = "altgraph-0.17.4.tar.gz", hash = "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406"}, @@ -17,6 +19,7 @@ version = "1.3.0" description = "Better dates & times for Python" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "arrow-1.3.0-py3-none-any.whl", hash = "sha256:c728b120ebc00eb84e01882a6f5e7927a53960aa990ce7dd2b10f39005a67f80"}, {file = "arrow-1.3.0.tar.gz", hash = "sha256:d4540617648cb5f895730f1ad8c82a65f2dad0166f57b75f3ca54759c4d67a85"}, @@ -36,6 +39,7 @@ version = "0.4.4" description = "Ultra-lightweight pure Python package to check if a file is binary or text." optional = false python-versions = "*" +groups = ["main"] files = [ {file = "binaryornot-0.4.4-py2.py3-none-any.whl", hash = "sha256:b8b71173c917bddcd2c16070412e369c3ed7f0528926f70cac18a6c97fd563e4"}, {file = "binaryornot-0.4.4.tar.gz", hash = "sha256:359501dfc9d40632edc9fac890e19542db1a287bbcfa58175b66658392018061"}, @@ -50,6 +54,7 @@ version = "2024.8.30" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" +groups = ["main", "test"] files = [ {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, @@ -61,6 +66,7 @@ version = "5.2.0" description = "Universal encoding detector for Python 3" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, @@ -72,6 +78,7 @@ version = "3.4.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7.0" +groups = ["main", "test"] files = [ {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, @@ -186,6 +193,7 @@ version = "8.1.7" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, @@ -200,6 +208,7 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "test"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -211,6 +220,7 @@ version = "7.2.7" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.7" +groups = ["test"] files = [ {file = "coverage-7.2.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8"}, {file = "coverage-7.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb"}, @@ -275,7 +285,7 @@ files = [ ] [package.extras] -toml = ["tomli"] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "dunamai" @@ -283,6 +293,7 @@ version = "1.21.2" description = "Dynamic version generation" optional = false python-versions = ">=3.5" +groups = ["executable"] files = [ {file = "dunamai-1.21.2-py3-none-any.whl", hash = "sha256:87db76405bf9366f9b4925ff5bb1db191a9a1bd9f9693f81c4d3abb8298be6f0"}, {file = "dunamai-1.21.2.tar.gz", hash = "sha256:05827fb5f032f5596bfc944b23f613c147e676de118681f3bb1559533d8a65c4"}, @@ -297,6 +308,8 @@ version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" +groups = ["test"] +markers = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, @@ -311,6 +324,7 @@ version = "4.0.11" description = "Git Object Database" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "gitdb-4.0.11-py3-none-any.whl", hash = "sha256:81a3407ddd2ee8df444cbacea00e2d038e40150acfa3001696fe0dcf1d3adfa4"}, {file = "gitdb-4.0.11.tar.gz", hash = "sha256:bf5421126136d6d0af55bc1e7c1af1c397a34f5b7bd79e776cd3e89785c2b04b"}, @@ -325,6 +339,7 @@ version = "3.1.43" description = "GitPython is a Python library used to interact with Git repositories" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "GitPython-3.1.43-py3-none-any.whl", hash = "sha256:eec7ec56b92aad751f9912a73404bc02ba212a23adb2c7098ee668417051a1ff"}, {file = "GitPython-3.1.43.tar.gz", hash = "sha256:35f314a9f878467f5453cc1fee295c3e18e52f1b99f10f6cf5b1682e968a9e7c"}, @@ -335,7 +350,7 @@ gitdb = ">=4.0.1,<5" [package.extras] doc = ["sphinx (==4.3.2)", "sphinx-autodoc-typehints", "sphinx-rtd-theme", "sphinxcontrib-applehelp (>=1.0.2,<=1.0.4)", "sphinxcontrib-devhelp (==1.0.2)", "sphinxcontrib-htmlhelp (>=2.0.0,<=2.0.1)", "sphinxcontrib-qthelp (==1.0.3)", "sphinxcontrib-serializinghtml (==1.1.5)"] -test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions"] +test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock ; python_version < \"3.8\"", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions ; python_version < \"3.11\""] [[package]] name = "idna" @@ -343,6 +358,7 @@ version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" +groups = ["main", "test"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -357,6 +373,8 @@ version = "8.5.0" description = "Read metadata from Python packages" optional = false python-versions = ">=3.8" +groups = ["executable"] +markers = "python_version < \"3.10\"" files = [ {file = "importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b"}, {file = "importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7"}, @@ -366,12 +384,12 @@ files = [ zipp = ">=3.20" [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] perf = ["ipython"] -test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +test = ["flufl.flake8", "importlib-resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] type = ["pytest-mypy"] [[package]] @@ -380,6 +398,7 @@ version = "2.0.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.7" +groups = ["test"] files = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, @@ -391,6 +410,8 @@ version = "1.16.3" description = "Mach-O header analysis and editing" optional = false python-versions = "*" +groups = ["executable"] +markers = "python_version < \"3.13\" and sys_platform == \"darwin\"" files = [ {file = "macholib-1.16.3-py2.py3-none-any.whl", hash = "sha256:0e315d7583d38b8c77e815b1ecbdbf504a8258d8b3e17b61165c6feb60d18f2c"}, {file = "macholib-1.16.3.tar.gz", hash = "sha256:07ae9e15e8e4cd9a788013d81f5908b3609aa76f9b1421bae9c4d7606ec86a30"}, @@ -405,6 +426,7 @@ version = "3.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, @@ -429,6 +451,7 @@ version = "3.22.0" description = "A lightweight library for converting complex datatypes to and from native Python datatypes." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "marshmallow-3.22.0-py3-none-any.whl", hash = "sha256:71a2dce49ef901c3f97ed296ae5051135fd3febd2bf43afe0ae9a82143a494d9"}, {file = "marshmallow-3.22.0.tar.gz", hash = "sha256:4972f529104a220bb8637d595aa4c9762afbe7f7a77d82dc58c1615d70c5823e"}, @@ -448,6 +471,7 @@ version = "0.1.2" description = "Markdown URL utilities" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, @@ -459,6 +483,7 @@ version = "4.0.3" description = "Rolling backport of unittest.mock for all Pythons" optional = false python-versions = ">=3.6" +groups = ["test"] files = [ {file = "mock-4.0.3-py3-none-any.whl", hash = "sha256:122fcb64ee37cfad5b3f48d7a7d51875d7031aaf3d8be7c42e2bee25044eee62"}, {file = "mock-4.0.3.tar.gz", hash = "sha256:7d3fbbde18228f4ff2f1f119a45cdffa458b4c0dee32eb4d2bb2f82554bac7bc"}, @@ -475,6 +500,7 @@ version = "24.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" +groups = ["main", "executable", "test"] files = [ {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, @@ -486,6 +512,7 @@ version = "1.18.1" description = "Library to parse and apply unified diffs." optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "patch-ng-1.18.1.tar.gz", hash = "sha256:52fd46ee46f6c8667692682c1fd7134edc65a2d2d084ebec1d295a6087fc0291"}, ] @@ -496,6 +523,8 @@ version = "2024.8.26" description = "Python PE parsing module" optional = false python-versions = ">=3.6.0" +groups = ["executable"] +markers = "python_version < \"3.13\" and sys_platform == \"win32\"" files = [ {file = "pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f"}, {file = "pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632"}, @@ -507,6 +536,7 @@ version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" +groups = ["test"] files = [ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, @@ -522,6 +552,7 @@ version = "5.7.2" description = "pyfakefs implements a fake file system that mocks the Python file system modules." optional = false python-versions = ">=3.7" +groups = ["test"] files = [ {file = "pyfakefs-5.7.2-py3-none-any.whl", hash = "sha256:e1527b0e8e4b33be52f0b024ca1deb269c73eecd68457c6b0bf608d6dab12ebd"}, {file = "pyfakefs-5.7.2.tar.gz", hash = "sha256:40da84175c5af8d9c4f3b31800b8edc4af1e74a212671dd658b21cc881c60000"}, @@ -533,6 +564,7 @@ version = "2.18.0" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, @@ -547,6 +579,8 @@ version = "5.13.2" description = "PyInstaller bundles a Python application and all its dependencies into a single package." optional = false python-versions = "<3.13,>=3.7" +groups = ["executable"] +markers = "python_version < \"3.13\"" files = [ {file = "pyinstaller-5.13.2-py3-none-macosx_10_13_universal2.whl", hash = "sha256:16cbd66b59a37f4ee59373a003608d15df180a0d9eb1a29ff3bfbfae64b23d0f"}, {file = "pyinstaller-5.13.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8f6dd0e797ae7efdd79226f78f35eb6a4981db16c13325e962a83395c0ec7420"}, @@ -580,6 +614,8 @@ version = "2024.10" description = "Community maintained hooks for PyInstaller" optional = false python-versions = ">=3.8" +groups = ["executable"] +markers = "python_version < \"3.13\"" files = [ {file = "pyinstaller_hooks_contrib-2024.10-py3-none-any.whl", hash = "sha256:ad47db0e153683b4151e10d231cb91f2d93c85079e78d76d9e0f57ac6c8a5e10"}, {file = "pyinstaller_hooks_contrib-2024.10.tar.gz", hash = "sha256:8a46655e5c5b0186b5e527399118a9b342f10513eb1425c483fa4f6d02e8800c"}, @@ -596,6 +632,7 @@ version = "2.9.0" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "PyJWT-2.9.0-py3-none-any.whl", hash = "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850"}, {file = "pyjwt-2.9.0.tar.gz", hash = "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c"}, @@ -613,6 +650,7 @@ version = "7.3.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.7" +groups = ["test"] files = [ {file = "pytest-7.3.2-py3-none-any.whl", hash = "sha256:cdcbd012c9312258922f8cd3f1b62a6580fdced17db6014896053d47cddf9295"}, {file = "pytest-7.3.2.tar.gz", hash = "sha256:ee990a3cc55ba808b80795a79944756f315c67c12b56abd3ac993a7b8c17030b"}, @@ -635,6 +673,7 @@ version = "3.10.0" description = "Thin-wrapper around the mock package for easier use with pytest" optional = false python-versions = ">=3.7" +groups = ["test"] files = [ {file = "pytest-mock-3.10.0.tar.gz", hash = "sha256:fbbdb085ef7c252a326fd8cdcac0aa3b1333d8811f131bdcc701002e1be7ed4f"}, {file = "pytest_mock-3.10.0-py3-none-any.whl", hash = "sha256:f4c973eeae0282963eb293eb173ce91b091a79c1334455acfac9ddee8a1c784b"}, @@ -652,6 +691,7 @@ version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -666,6 +706,8 @@ version = "0.2.3" description = "A (partial) reimplementation of pywin32 using ctypes/cffi" optional = false python-versions = ">=3.6" +groups = ["executable"] +markers = "python_version < \"3.13\" and sys_platform == \"win32\"" files = [ {file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"}, {file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"}, @@ -677,6 +719,7 @@ version = "6.0.2" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" +groups = ["main", "test"] files = [ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, @@ -739,6 +782,7 @@ version = "2.32.3" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" +groups = ["main", "test"] files = [ {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, @@ -760,6 +804,7 @@ version = "0.23.3" description = "A utility library for mocking out the `requests` Python library." optional = false python-versions = ">=3.7" +groups = ["test"] files = [ {file = "responses-0.23.3-py3-none-any.whl", hash = "sha256:e6fbcf5d82172fecc0aa1860fd91e58cbfd96cee5e96da5b63fa6eb3caa10dd3"}, {file = "responses-0.23.3.tar.gz", hash = "sha256:205029e1cb334c21cb4ec64fc7599be48b859a0fd381a42443cdd600bfe8b16a"}, @@ -772,7 +817,7 @@ types-PyYAML = "*" urllib3 = ">=1.25.10,<3.0" [package.extras] -tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli", "tomli-w", "types-requests"] +tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli ; python_version < \"3.11\"", "tomli-w", "types-requests"] [[package]] name = "rich" @@ -780,6 +825,7 @@ version = "13.9.4" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.8.0" +groups = ["main"] files = [ {file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"}, {file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"}, @@ -799,6 +845,7 @@ version = "0.6.9" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "ruff-0.6.9-py3-none-linux_armv6l.whl", hash = "sha256:064df58d84ccc0ac0fcd63bc3090b251d90e2a372558c0f057c3f75ed73e1ccd"}, {file = "ruff-0.6.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:140d4b5c9f5fc7a7b074908a78ab8d384dd7f6510402267bc76c37195c02a7ec"}, @@ -826,6 +873,7 @@ version = "2.19.2" description = "Python client for Sentry (https://sentry.io)" optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "sentry_sdk-2.19.2-py2.py3-none-any.whl", hash = "sha256:ebdc08228b4d131128e568d696c210d846e5b9d70aa0327dec6b1272d9d40b84"}, {file = "sentry_sdk-2.19.2.tar.gz", hash = "sha256:467df6e126ba242d39952375dd816fbee0f217d119bf454a8ce74cf1e7909e8d"}, @@ -880,19 +928,33 @@ version = "75.3.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" +groups = ["executable"] +markers = "python_version < \"3.13\"" files = [ {file = "setuptools-75.3.0-py3-none-any.whl", hash = "sha256:f2504966861356aa38616760c0f66568e535562374995367b4e69c7143cf6bcd"}, {file = "setuptools-75.3.0.tar.gz", hash = "sha256:fba5dd4d766e97be1b1681d98712680ae8f2f26d7881245f2ce9e40714f1a686"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"] -core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.5.2) ; sys_platform != \"cygwin\""] +core = ["importlib-metadata (>=6) ; python_version < \"3.10\"", "importlib-resources (>=5.10.2) ; python_version < \"3.9\"", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test (>=5.5)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.12.*)", "pytest-mypy"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test (>=5.5)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib-metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.12.*)", "pytest-mypy"] + +[[package]] +name = "shellingham" +version = "1.5.4" +description = "Tool to Detect Surrounding Shell" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, +] [[package]] name = "six" @@ -900,6 +962,7 @@ version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -911,6 +974,7 @@ version = "5.0.1" description = "A pure Python implementation of a sliding window memory map manager" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "smmap-5.0.1-py3-none-any.whl", hash = "sha256:e6d8668fa5f93e706934a62d7b4db19c8d9eb8cf2adbb75ef1b675aa332b69da"}, {file = "smmap-5.0.1.tar.gz", hash = "sha256:dceeb6c0028fdb6734471eb07c0cd2aae706ccaecab45965ee83f11c8d3b1f62"}, @@ -922,6 +986,7 @@ version = "1.7.0" description = "module to create simple ASCII tables" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "texttable-1.7.0-py2.py3-none-any.whl", hash = "sha256:72227d592c82b3d7f672731ae73e4d1f88cd8e2ef5b075a7a7f01a23a3743917"}, {file = "texttable-1.7.0.tar.gz", hash = "sha256:2d2068fb55115807d3ac77a4ca68fa48803e84ebb0ee2340f858107a36522638"}, @@ -933,6 +998,8 @@ version = "2.2.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" +groups = ["test"] +markers = "python_version < \"3.11\"" files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, @@ -968,12 +1035,31 @@ files = [ {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, ] +[[package]] +name = "typer" +version = "0.15.2" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc"}, + {file = "typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5"}, +] + +[package.dependencies] +click = ">=8.0.0" +rich = ">=10.11.0" +shellingham = ">=1.3.0" +typing-extensions = ">=3.7.4.3" + [[package]] name = "types-python-dateutil" version = "2.9.0.20241206" description = "Typing stubs for python-dateutil" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "types_python_dateutil-2.9.0.20241206-py3-none-any.whl", hash = "sha256:e248a4bc70a486d3e3ec84d0dc30eec3a5f979d6e7ee4123ae043eedbb987f53"}, {file = "types_python_dateutil-2.9.0.20241206.tar.gz", hash = "sha256:18f493414c26ffba692a72369fea7a154c502646301ebfe3d56a04b3767284cb"}, @@ -985,6 +1071,7 @@ version = "6.0.12.20240917" description = "Typing stubs for PyYAML" optional = false python-versions = ">=3.8" +groups = ["test"] files = [ {file = "types-PyYAML-6.0.12.20240917.tar.gz", hash = "sha256:d1405a86f9576682234ef83bcb4e6fff7c9305c8b1fbad5e0bcd4f7dbdc9c587"}, {file = "types_PyYAML-6.0.12.20240917-py3-none-any.whl", hash = "sha256:392b267f1c0fe6022952462bf5d6523f31e37f6cea49b14cee7ad634b6301570"}, @@ -996,6 +1083,7 @@ version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, @@ -1007,14 +1095,15 @@ version = "1.26.19" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +groups = ["main", "test"] files = [ {file = "urllib3-1.26.19-py2.py3-none-any.whl", hash = "sha256:37a0344459b199fce0e80b0d3569837ec6b6937435c5244e7fd73fa6006830f3"}, {file = "urllib3-1.26.19.tar.gz", hash = "sha256:3e3d753a8618b86d7de333b4223005f68720bcd6a7d2bcb9fbd2229ec7c1e429"}, ] [package.extras] -brotli = ["brotli (==1.0.9)", "brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] -secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] +brotli = ["brotli (==1.0.9) ; os_name != \"nt\" and python_version < \"3\" and platform_python_implementation == \"CPython\"", "brotli (>=1.0.9) ; python_version >= \"3\" and platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; (os_name != \"nt\" or python_version >= \"3\") and platform_python_implementation != \"CPython\"", "brotlipy (>=0.6.0) ; os_name == \"nt\" and python_version < \"3\""] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress ; python_version == \"2.7\"", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] @@ -1023,20 +1112,22 @@ version = "3.20.2" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.8" +groups = ["executable"] +markers = "python_version < \"3.10\"" files = [ {file = "zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350"}, {file = "zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] type = ["pytest-mypy"] [metadata] -lock-version = "2.0" -python-versions = ">=3.8,<3.14" -content-hash = "e91a6f9b7e080cea351f9073ef333afe026df6172b95fba5477af67f15c96000" +lock-version = "2.1" +python-versions = ">=3.9,<3.14" +content-hash = "c0140dc408f1e3827b51357d74b05274297c233de11dbca85d4b6f3a909f4191" diff --git a/pyproject.toml b/pyproject.toml index 42511ec8..6baffad9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,6 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -24,10 +23,10 @@ classifiers = [ ] [tool.poetry.scripts] -cycode = "cycode.cli.main:main_cli" +cycode = "cycode.cli.app:app" [tool.poetry.dependencies] -python = ">=3.8,<3.14" +python = ">=3.9,<3.14" click = ">=8.1.0,<8.2.0" colorama = ">=0.4.3,<0.5.0" pyyaml = ">=6.0,<7.0" @@ -42,6 +41,7 @@ sentry-sdk = ">=2.8.0,<3.0" pyjwt = ">=2.8.0,<3.0" rich = ">=13.9.4, <14" patch-ng = "1.18.1" +typer = "^0.15.2" [tool.poetry.group.test.dependencies] mock = ">=4.0.3,<4.1.0" diff --git a/tests/cli/commands/configure/test_configure_command.py b/tests/cli/commands/configure/test_configure_command.py index c5ae2b9c..5ed94c1d 100644 --- a/tests/cli/commands/configure/test_configure_command.py +++ b/tests/cli/commands/configure/test_configure_command.py @@ -1,8 +1,8 @@ from typing import TYPE_CHECKING -from click.testing import CliRunner +from typer.testing import CliRunner -from cycode.cli.commands.configure.configure_command import configure_command +from cycode.cli.app import app if TYPE_CHECKING: from pytest_mock import MockerFixture @@ -30,7 +30,7 @@ def test_configure_command_no_exist_values_in_file(mocker: 'MockerFixture') -> N # side effect - multiple return values, each item in the list represents return of a call mocker.patch( - 'click.prompt', + 'typer.prompt', side_effect=[api_url_user_input, app_url_user_input, client_id_user_input, client_secret_user_input], ) @@ -45,7 +45,7 @@ def test_configure_command_no_exist_values_in_file(mocker: 'MockerFixture') -> N ) # Act - CliRunner().invoke(configure_command) + CliRunner().invoke(app, ['configure']) # Assert mocked_update_credentials.assert_called_once_with(client_id_user_input, client_secret_user_input) @@ -75,7 +75,7 @@ def test_configure_command_update_current_configs_in_files(mocker: 'MockerFixtur # side effect - multiple return values, each item in the list represents return of a call mocker.patch( - 'click.prompt', + 'typer.prompt', side_effect=[api_url_user_input, app_url_user_input, client_id_user_input, client_secret_user_input], ) @@ -90,7 +90,7 @@ def test_configure_command_update_current_configs_in_files(mocker: 'MockerFixtur ) # Act - CliRunner().invoke(configure_command) + CliRunner().invoke(app, ['configure']) # Assert mocked_update_credentials.assert_called_once_with(client_id_user_input, client_secret_user_input) @@ -108,13 +108,13 @@ def test_set_credentials_update_only_client_id(mocker: 'MockerFixture') -> None: ) # side effect - multiple return values, each item in the list represents return of a call - mocker.patch('click.prompt', side_effect=['', '', client_id_user_input, '']) + mocker.patch('typer.prompt', side_effect=['', '', client_id_user_input, '']) mocked_update_credentials = mocker.patch( 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials' ) # Act - CliRunner().invoke(configure_command) + CliRunner().invoke(app, ['configure']) # Assert mocked_update_credentials.assert_called_once_with(client_id_user_input, current_client_id) @@ -131,13 +131,13 @@ def test_configure_command_update_only_client_secret(mocker: 'MockerFixture') -> ) # side effect - multiple return values, each item in the list represents return of a call - mocker.patch('click.prompt', side_effect=['', '', '', client_secret_user_input]) + mocker.patch('typer.prompt', side_effect=['', '', '', client_secret_user_input]) mocked_update_credentials = mocker.patch( 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials' ) # Act - CliRunner().invoke(configure_command) + CliRunner().invoke(app, ['configure']) # Assert mocked_update_credentials.assert_called_once_with(current_client_id, client_secret_user_input) @@ -154,13 +154,13 @@ def test_configure_command_update_only_api_url(mocker: 'MockerFixture') -> None: ) # side effect - multiple return values, each item in the list represents return of a call - mocker.patch('click.prompt', side_effect=[api_url_user_input, '', '', '']) + mocker.patch('typer.prompt', side_effect=[api_url_user_input, '', '', '']) mocked_update_api_base_url = mocker.patch( 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_api_base_url' ) # Act - CliRunner().invoke(configure_command) + CliRunner().invoke(app, ['configure']) # Assert mocked_update_api_base_url.assert_called_once_with(api_url_user_input) @@ -177,13 +177,13 @@ def test_configure_command_should_not_update_credentials(mocker: 'MockerFixture' ) # side effect - multiple return values, each item in the list represents return of a call - mocker.patch('click.prompt', side_effect=['', '', client_id_user_input, client_secret_user_input]) + mocker.patch('typer.prompt', side_effect=['', '', client_id_user_input, client_secret_user_input]) mocked_update_credentials = mocker.patch( 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials' ) # Act - CliRunner().invoke(configure_command) + CliRunner().invoke(app, ['configure']) # Assert assert not mocked_update_credentials.called @@ -204,7 +204,7 @@ def test_configure_command_should_not_update_config_file(mocker: 'MockerFixture' ) # side effect - multiple return values, each item in the list represents return of a call - mocker.patch('click.prompt', side_effect=[api_url_user_input, app_url_user_input, '', '']) + mocker.patch('typer.prompt', side_effect=[api_url_user_input, app_url_user_input, '', '']) mocked_update_api_base_url = mocker.patch( 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_api_base_url' ) @@ -213,7 +213,7 @@ def test_configure_command_should_not_update_config_file(mocker: 'MockerFixture' ) # Act - CliRunner().invoke(configure_command) + CliRunner().invoke(app, ['configure']) # Assert assert not mocked_update_api_base_url.called diff --git a/tests/cli/commands/scan/test_code_scanner.py b/tests/cli/commands/scan/test_code_scanner.py index 2c15dd3d..3151684e 100644 --- a/tests/cli/commands/scan/test_code_scanner.py +++ b/tests/cli/commands/scan/test_code_scanner.py @@ -1,7 +1,7 @@ import os from cycode.cli import consts -from cycode.cli.commands.scan.code_scanner import _does_severity_match_severity_threshold +from cycode.cli.apps.scan.code_scanner import _does_severity_match_severity_threshold from cycode.cli.files_collector.excluder import _is_file_relevant_for_sca_scan from cycode.cli.files_collector.path_documents import _generate_document from cycode.cli.models import Document @@ -42,7 +42,7 @@ def test_generate_document() -> None: }""" iac_document = Document(path, content, is_git_diff) - generated_document = _generate_document(path, consts.INFRA_CONFIGURATION_SCAN_TYPE, content, is_git_diff) + generated_document = _generate_document(path, consts.IAC_SCAN_TYPE, content, is_git_diff) assert iac_document.path == generated_document.path assert iac_document.content == generated_document.content assert iac_document.is_git_diff_format == generated_document.is_git_diff_format @@ -68,7 +68,7 @@ def test_generate_document() -> None: } """ - generated_tfplan_document = _generate_document(path, consts.INFRA_CONFIGURATION_SCAN_TYPE, content, is_git_diff) + generated_tfplan_document = _generate_document(path, consts.IAC_SCAN_TYPE, content, is_git_diff) assert isinstance(generated_tfplan_document, Document) assert generated_tfplan_document.path.endswith('.tf') diff --git a/tests/cli/commands/test_check_latest_version_on_close.py b/tests/cli/commands/test_check_latest_version_on_close.py index 189973b4..b1f11e24 100644 --- a/tests/cli/commands/test_check_latest_version_on_close.py +++ b/tests/cli/commands/test_check_latest_version_on_close.py @@ -1,11 +1,12 @@ from unittest.mock import patch import pytest -from click.testing import CliRunner +from typer.testing import CliRunner from cycode import __version__ -from cycode.cli.commands.main_cli import main_cli -from cycode.cli.commands.version.version_checker import VersionChecker +from cycode.cli.app import app +from cycode.cli.cli_types import OutputTypeOption +from cycode.cli.utils.version_checker import VersionChecker from tests.conftest import CLI_ENV_VARS _NEW_LATEST_VERSION = '999.0.0' # Simulate a newer version available @@ -17,8 +18,8 @@ def test_version_check_with_json_output(mock_check_update: patch) -> None: # When output is JSON, version check should be skipped mock_check_update.return_value = _NEW_LATEST_VERSION - args = ['--output', 'json', 'version'] - result = CliRunner().invoke(main_cli, args, env=CLI_ENV_VARS) + args = ['--output', OutputTypeOption.JSON, 'version'] + result = CliRunner().invoke(app, args, env=CLI_ENV_VARS) # Version check message should not be present in JSON output assert _UPDATE_MESSAGE_PART not in result.output.lower() @@ -28,7 +29,7 @@ def test_version_check_with_json_output(mock_check_update: patch) -> None: @pytest.fixture def mock_auth_info() -> 'patch': # Mock the authorization info to avoid API calls - with patch('cycode.cli.commands.status.status_command.get_authorization_info', return_value=None) as mock: + with patch('cycode.cli.apps.auth.auth_common.get_authorization_info', return_value=None) as mock: yield mock @@ -38,7 +39,7 @@ def test_version_check_for_special_commands(mock_check_update: patch, mock_auth_ # Version and status commands should always check the version without cache mock_check_update.return_value = _NEW_LATEST_VERSION - result = CliRunner().invoke(main_cli, [command], env=CLI_ENV_VARS) + result = CliRunner().invoke(app, [command], env=CLI_ENV_VARS) # Version information should be present in output assert _UPDATE_MESSAGE_PART in result.output.lower() @@ -52,7 +53,7 @@ def test_version_check_with_text_output(mock_check_update: patch) -> None: mock_check_update.return_value = _NEW_LATEST_VERSION args = ['version'] - result = CliRunner().invoke(main_cli, args, env=CLI_ENV_VARS) + result = CliRunner().invoke(app, args, env=CLI_ENV_VARS) # Version check message should be present in JSON output assert _UPDATE_MESSAGE_PART in result.output.lower() @@ -64,7 +65,7 @@ def test_version_check_disabled(mock_check_update: patch) -> None: mock_check_update.return_value = _NEW_LATEST_VERSION args = ['--no-update-notifier', 'version'] - result = CliRunner().invoke(main_cli, args, env=CLI_ENV_VARS) + result = CliRunner().invoke(app, args, env=CLI_ENV_VARS) # Version check message should not be present assert _UPDATE_MESSAGE_PART not in result.output.lower() diff --git a/tests/cli/commands/test_main_command.py b/tests/cli/commands/test_main_command.py index 7e588cf2..d7575ddb 100644 --- a/tests/cli/commands/test_main_command.py +++ b/tests/cli/commands/test_main_command.py @@ -4,10 +4,11 @@ import pytest import responses -from click.testing import CliRunner +from typer.testing import CliRunner from cycode.cli import consts -from cycode.cli.commands.main_cli import main_cli +from cycode.cli.app import app +from cycode.cli.cli_types import OutputTypeOption from cycode.cli.utils.git_proxy import git_proxy from tests.conftest import CLI_ENV_VARS, TEST_FILES_PATH, ZIP_CONTENT_PATH from tests.cyclient.mocked_responses.scan_client import mock_scan_responses @@ -28,7 +29,7 @@ def _is_json(plain: str) -> bool: @responses.activate -@pytest.mark.parametrize('output', ['text', 'json']) +@pytest.mark.parametrize('output', [OutputTypeOption.TEXT, OutputTypeOption.JSON]) def test_passing_output_option(output: str, scan_client: 'ScanClient', api_token_response: responses.Response) -> None: scan_type = consts.SECRET_SCAN_TYPE scan_id = uuid4() @@ -38,7 +39,7 @@ def test_passing_output_option(output: str, scan_client: 'ScanClient', api_token responses.add(api_token_response) args = ['--output', output, 'scan', '--soft-fail', 'path', str(_PATH_TO_SCAN)] - result = CliRunner().invoke(main_cli, args, env=CLI_ENV_VARS) + result = CliRunner().invoke(app, args, env=CLI_ENV_VARS) except_json = output == 'json' @@ -63,7 +64,7 @@ def test_optional_git_with_path_scan(scan_client: 'ScanClient', api_token_respon git_proxy._set_dummy_git_proxy() args = ['--output', 'json', 'scan', 'path', str(_PATH_TO_SCAN)] - result = CliRunner().invoke(main_cli, args, env=CLI_ENV_VARS) + result = CliRunner().invoke(app, args, env=CLI_ENV_VARS) # do NOT expect error about not found Git executable assert 'GIT_PYTHON_GIT_EXECUTABLE' not in result.output @@ -79,8 +80,8 @@ def test_required_git_with_path_repository(scan_client: 'ScanClient', api_token_ # fake env without Git executable git_proxy._set_dummy_git_proxy() - args = ['--output', 'json', 'scan', 'repository', str(_PATH_TO_SCAN)] - result = CliRunner().invoke(main_cli, args, env=CLI_ENV_VARS) + args = ['--output', OutputTypeOption.JSON, 'scan', 'repository', str(_PATH_TO_SCAN)] + result = CliRunner().invoke(app, args, env=CLI_ENV_VARS) # expect error about not found Git executable assert 'GIT_PYTHON_GIT_EXECUTABLE' in result.output diff --git a/tests/cli/commands/version/test_version_checker.py b/tests/cli/commands/version/test_version_checker.py index eb0b9bd9..926a21e8 100644 --- a/tests/cli/commands/version/test_version_checker.py +++ b/tests/cli/commands/version/test_version_checker.py @@ -7,7 +7,7 @@ if TYPE_CHECKING: from pathlib import Path -from cycode.cli.commands.version.version_checker import VersionChecker +from cycode.cli.utils.version_checker import VersionChecker @pytest.fixture diff --git a/tests/cli/exceptions/test_handle_scan_errors.py b/tests/cli/exceptions/test_handle_scan_errors.py index 82a44bb0..c1d34306 100644 --- a/tests/cli/exceptions/test_handle_scan_errors.py +++ b/tests/cli/exceptions/test_handle_scan_errors.py @@ -2,9 +2,11 @@ import click import pytest +import typer from click import ClickException from requests import Response +from cycode.cli.cli_types import OutputTypeOption from cycode.cli.exceptions import custom_exceptions from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception from cycode.cli.utils.git_proxy import git_proxy @@ -14,8 +16,8 @@ @pytest.fixture() -def ctx() -> click.Context: - return click.Context(click.Command('path'), obj={'verbose': False, 'output': 'text'}) +def ctx() -> typer.Context: + return typer.Context(click.Command('path'), obj={'verbose': False, 'output': OutputTypeOption.TEXT}) @pytest.mark.parametrize( @@ -30,7 +32,7 @@ def ctx() -> click.Context: ], ) def test_handle_exception_soft_fail( - ctx: click.Context, exception: custom_exceptions.CycodeError, expected_soft_fail: bool + ctx: typer.Context, exception: custom_exceptions.CycodeError, expected_soft_fail: bool ) -> None: with ctx: handle_scan_exception(ctx, exception) @@ -39,15 +41,15 @@ def test_handle_exception_soft_fail( assert ctx.obj.get('soft_fail') is expected_soft_fail -def test_handle_exception_unhandled_error(ctx: click.Context) -> None: - with ctx, pytest.raises(SystemExit): +def test_handle_exception_unhandled_error(ctx: typer.Context) -> None: + with ctx, pytest.raises(typer.Exit): handle_scan_exception(ctx, ValueError('test')) assert ctx.obj.get('did_fail') is True assert ctx.obj.get('soft_fail') is None -def test_handle_exception_click_error(ctx: click.Context) -> None: +def test_handle_exception_click_error(ctx: typer.Context) -> None: with ctx, pytest.raises(ClickException): handle_scan_exception(ctx, click.ClickException('test')) @@ -56,7 +58,7 @@ def test_handle_exception_click_error(ctx: click.Context) -> None: def test_handle_exception_verbose(monkeypatch: 'MonkeyPatch') -> None: - ctx = click.Context(click.Command('path'), obj={'verbose': True, 'output': 'text'}) + ctx = typer.Context(click.Command('path'), obj={'verbose': True, 'output': OutputTypeOption.TEXT}) error_text = 'test' @@ -65,5 +67,5 @@ def mock_secho(msg: str, *_, **__) -> None: monkeypatch.setattr(click, 'secho', mock_secho) - with pytest.raises(SystemExit): + with pytest.raises(typer.Exit): handle_scan_exception(ctx, ValueError(error_text)) diff --git a/tests/cli/models/test_severity.py b/tests/cli/models/test_severity.py index 332f987c..a59d5751 100644 --- a/tests/cli/models/test_severity.py +++ b/tests/cli/models/test_severity.py @@ -1,24 +1,11 @@ -from cycode.cli.models import Severity - - -def test_try_get_value() -> None: - assert Severity.try_get_value('info') == -1 - assert Severity.try_get_value('iNfO') == -1 - - assert Severity.try_get_value('INFO') == -1 - assert Severity.try_get_value('LOW') == 0 - assert Severity.try_get_value('MEDIUM') == 1 - assert Severity.try_get_value('HIGH') == 2 - assert Severity.try_get_value('CRITICAL') == 3 - - assert Severity.try_get_value('NON_EXISTENT') is None +from cycode.cli.cli_types import SeverityOption def test_get_member_weight() -> None: - assert Severity.get_member_weight('INFO') == -1 - assert Severity.get_member_weight('LOW') == 0 - assert Severity.get_member_weight('MEDIUM') == 1 - assert Severity.get_member_weight('HIGH') == 2 - assert Severity.get_member_weight('CRITICAL') == 3 + assert SeverityOption.get_member_weight('INFO') == 0 + assert SeverityOption.get_member_weight('LOW') == 1 + assert SeverityOption.get_member_weight('MEDIUM') == 2 + assert SeverityOption.get_member_weight('HIGH') == 3 + assert SeverityOption.get_member_weight('CRITICAL') == 4 - assert Severity.get_member_weight('NON_EXISTENT') == -2 + assert SeverityOption.get_member_weight('NON_EXISTENT') == -1 diff --git a/tests/cyclient/scan_config/test_default_scan_config.py b/tests/cyclient/scan_config/test_default_scan_config.py index 75b305b5..7371250c 100644 --- a/tests/cyclient/scan_config/test_default_scan_config.py +++ b/tests/cyclient/scan_config/test_default_scan_config.py @@ -6,7 +6,7 @@ def test_get_service_name() -> None: default_scan_config = DefaultScanConfig() assert default_scan_config.get_service_name(consts.SECRET_SCAN_TYPE) == 'secret' - assert default_scan_config.get_service_name(consts.INFRA_CONFIGURATION_SCAN_TYPE) == 'iac' + assert default_scan_config.get_service_name(consts.IAC_SCAN_TYPE) == 'iac' assert default_scan_config.get_service_name(consts.SCA_SCAN_TYPE) == 'scans' assert default_scan_config.get_service_name(consts.SAST_SCAN_TYPE) == 'scans' assert default_scan_config.get_service_name(consts.SECRET_SCAN_TYPE, True) == 'scans' diff --git a/tests/cyclient/scan_config/test_dev_scan_config.py b/tests/cyclient/scan_config/test_dev_scan_config.py index 63c99169..6ebb368b 100644 --- a/tests/cyclient/scan_config/test_dev_scan_config.py +++ b/tests/cyclient/scan_config/test_dev_scan_config.py @@ -6,7 +6,7 @@ def test_get_service_name() -> None: dev_scan_config = DevScanConfig() assert dev_scan_config.get_service_name(consts.SECRET_SCAN_TYPE) == '5025' - assert dev_scan_config.get_service_name(consts.INFRA_CONFIGURATION_SCAN_TYPE) == '5026' + assert dev_scan_config.get_service_name(consts.IAC_SCAN_TYPE) == '5026' assert dev_scan_config.get_service_name(consts.SCA_SCAN_TYPE) == '5004' assert dev_scan_config.get_service_name(consts.SAST_SCAN_TYPE) == '5004' assert dev_scan_config.get_service_name(consts.SECRET_SCAN_TYPE, should_use_scan_service=True) == '5004' diff --git a/tests/cyclient/test_auth_client.py b/tests/cyclient/test_auth_client.py index 1d7e6683..67147a6e 100644 --- a/tests/cyclient/test_auth_client.py +++ b/tests/cyclient/test_auth_client.py @@ -3,7 +3,7 @@ import responses from requests import Timeout -from cycode.cli.commands.auth.auth_manager import AuthManager +from cycode.cli.apps.auth.auth_manager import AuthManager from cycode.cli.exceptions.custom_exceptions import CycodeError, RequestTimeout from cycode.cyclient.auth_client import AuthClient from cycode.cyclient.models import ( diff --git a/tests/cyclient/test_scan_client.py b/tests/cyclient/test_scan_client.py index 2b8fc3f3..1b1724ca 100644 --- a/tests/cyclient/test_scan_client.py +++ b/tests/cyclient/test_scan_client.py @@ -51,7 +51,7 @@ def get_test_zip_file(scan_type: str) -> InMemoryZip: def test_get_service_name(scan_client: ScanClient) -> None: # TODO(MarshalX): get_service_name should be removed from ScanClient? Because it exists in ScanConfig assert scan_client.get_service_name(consts.SECRET_SCAN_TYPE) == 'secret' - assert scan_client.get_service_name(consts.INFRA_CONFIGURATION_SCAN_TYPE) == 'iac' + assert scan_client.get_service_name(consts.IAC_SCAN_TYPE) == 'iac' assert scan_client.get_service_name(consts.SCA_SCAN_TYPE) == 'scans' assert scan_client.get_service_name(consts.SAST_SCAN_TYPE) == 'scans' diff --git a/tests/test_code_scanner.py b/tests/test_code_scanner.py index 10726a65..d6341a7c 100644 --- a/tests/test_code_scanner.py +++ b/tests/test_code_scanner.py @@ -5,7 +5,7 @@ import responses from cycode.cli import consts -from cycode.cli.commands.scan.code_scanner import ( +from cycode.cli.apps.scan.code_scanner import ( _try_get_aggregation_report_url_if_needed, _try_get_report_url_if_needed, ) From 4ee8185678f87a83e07245118dbd6be9d65fa2f7 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 12 Mar 2025 13:19:01 +0100 Subject: [PATCH 147/257] CM-45717 - Add formatted and colorized text in logs (`--verbose` mode) (#285) --- cycode/cli/app.py | 2 +- cycode/cli/apps/auth/auth_command.py | 2 +- cycode/cli/apps/auth/auth_manager.py | 5 +- cycode/cli/apps/ignore/ignore_command.py | 2 +- cycode/cli/apps/report/sbom/common.py | 2 +- cycode/cli/apps/scan/code_scanner.py | 6 +- .../commit_history/commit_history_command.py | 2 +- cycode/cli/apps/scan/path/path_command.py | 2 +- .../scan/pre_receive/pre_receive_command.py | 2 +- .../scan/repository/repository_command.py | 2 +- cycode/cli/apps/scan/scan_command.py | 23 ++--- cycode/cli/apps/status/get_cli_status.py | 2 +- cycode/cli/config.py | 6 -- cycode/cli/config.yaml | 25 ----- cycode/cli/consts.py | 4 - cycode/cli/files_collector/excluder.py | 5 +- cycode/cli/files_collector/path_documents.py | 2 +- .../sca/base_restore_dependencies.py | 2 +- .../sca/go/restore_go_dependencies.py | 4 +- .../sca/maven/restore_gradle_dependencies.py | 3 +- .../files_collector/sca/sca_code_scanner.py | 8 +- cycode/cli/files_collector/walk_ignore.py | 2 +- cycode/cli/files_collector/zip_documents.py | 13 ++- cycode/cli/logger.py | 3 + cycode/cli/printers/printer_base.py | 20 ++-- cycode/cli/printers/text_printer.py | 35 +++---- cycode/cli/user_settings/base_file_manager.py | 6 +- cycode/cli/utils/path_utils.py | 2 +- cycode/cli/utils/progress_bar.py | 4 +- cycode/cli/utils/scan_batch.py | 5 +- cycode/cli/utils/sentry.py | 2 +- cycode/cli/utils/shell_executor.py | 8 +- cycode/cli/utils/yaml_utils.py | 28 +++--- cycode/config.py | 45 +++++++++ cycode/cyclient/config.py | 97 +------------------ cycode/cyclient/config.yaml | 5 - cycode/cyclient/logger.py | 3 + cycode/logger.py | 66 +++++++++++++ pyinstaller.spec | 1 - tests/cyclient/test_scan_client.py | 34 ++++--- tests/test_code_scanner.py | 22 ++--- 41 files changed, 251 insertions(+), 261 deletions(-) delete mode 100644 cycode/cli/config.yaml create mode 100644 cycode/cli/logger.py create mode 100644 cycode/config.py delete mode 100644 cycode/cyclient/config.yaml create mode 100644 cycode/cyclient/logger.py create mode 100644 cycode/logger.py diff --git a/cycode/cli/app.py b/cycode/cli/app.py index d3fc10ca..96389ef1 100644 --- a/cycode/cli/app.py +++ b/cycode/cli/app.py @@ -11,9 +11,9 @@ from cycode.cli.utils.progress_bar import SCAN_PROGRESS_BAR_SECTIONS, get_progress_bar from cycode.cli.utils.sentry import add_breadcrumb, init_sentry from cycode.cli.utils.version_checker import version_checker -from cycode.cyclient.config import set_logging_level from cycode.cyclient.cycode_client_base import CycodeClientBase from cycode.cyclient.models import UserAgentOptionScheme +from cycode.logger import set_logging_level app = typer.Typer( pretty_exceptions_show_locals=False, diff --git a/cycode/cli/apps/auth/auth_command.py b/cycode/cli/apps/auth/auth_command.py index c0b2fb89..8150be01 100644 --- a/cycode/cli/apps/auth/auth_command.py +++ b/cycode/cli/apps/auth/auth_command.py @@ -2,10 +2,10 @@ 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.printers import ConsolePrinter from cycode.cli.utils.sentry import add_breadcrumb -from cycode.cyclient import logger def auth_command(ctx: typer.Context) -> None: diff --git a/cycode/cli/apps/auth/auth_manager.py b/cycode/cli/apps/auth/auth_manager.py index ab621842..ee064f3c 100644 --- a/cycode/cli/apps/auth/auth_manager.py +++ b/cycode/cli/apps/auth/auth_manager.py @@ -8,14 +8,17 @@ 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 generate_random_string, hash_string_to_sha256 -from cycode.cyclient import logger from cycode.cyclient.auth_client import AuthClient from cycode.cyclient.models import ApiTokenGenerationPollingResponse +from cycode.logger import get_logger if TYPE_CHECKING: from cycode.cyclient.models import ApiToken +logger = get_logger('Auth Manager') + + class AuthManager: CODE_VERIFIER_LENGTH = 101 POLLING_WAIT_INTERVAL_IN_SECONDS = 3 diff --git a/cycode/cli/apps/ignore/ignore_command.py b/cycode/cli/apps/ignore/ignore_command.py index 3ac3ffff..47d4fa0d 100644 --- a/cycode/cli/apps/ignore/ignore_command.py +++ b/cycode/cli/apps/ignore/ignore_command.py @@ -7,10 +7,10 @@ from cycode.cli import consts from cycode.cli.cli_types import ScanTypeOption from cycode.cli.config import configuration_manager +from cycode.cli.logger import logger from cycode.cli.utils.path_utils import get_absolute_path, is_path_exists from cycode.cli.utils.sentry import add_breadcrumb from cycode.cli.utils.string_utils import hash_string_to_sha256 -from cycode.cyclient import logger def _is_package_pattern_valid(package: str) -> bool: diff --git a/cycode/cli/apps/report/sbom/common.py b/cycode/cli/apps/report/sbom/common.py index b296e525..dabaffef 100644 --- a/cycode/cli/apps/report/sbom/common.py +++ b/cycode/cli/apps/report/sbom/common.py @@ -7,8 +7,8 @@ from cycode.cli.apps.report.sbom.sbom_report_file import SbomReportFile from cycode.cli.config import configuration_manager from cycode.cli.exceptions.custom_exceptions import ReportAsyncError +from cycode.cli.logger import logger from cycode.cli.utils.progress_bar import SbomReportProgressBarSection -from cycode.cyclient import logger from cycode.cyclient.models import ReportExecutionSchema if TYPE_CHECKING: diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index 535507d7..a1a5d440 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -34,9 +34,8 @@ from cycode.cli.utils.progress_bar import ScanProgressBarSection from cycode.cli.utils.scan_batch import run_parallel_batched_scan from cycode.cli.utils.scan_utils import set_issue_detected -from cycode.cyclient import logger -from cycode.cyclient.config import set_logging_level from cycode.cyclient.models import Detection, DetectionSchema, DetectionsPerFile, ZippedFileScanResult +from cycode.logger import get_logger, set_logging_level if TYPE_CHECKING: from cycode.cyclient.models import ScanDetailsResponse @@ -45,6 +44,9 @@ start_scan_time = time.time() +logger = get_logger('Code Scanner') + + def scan_sca_pre_commit(ctx: typer.Context) -> None: scan_type = ctx.obj['scan_type'] scan_parameters = get_default_scan_parameters(ctx) diff --git a/cycode/cli/apps/scan/commit_history/commit_history_command.py b/cycode/cli/apps/scan/commit_history/commit_history_command.py index dd74a4f0..f7992a92 100644 --- a/cycode/cli/apps/scan/commit_history/commit_history_command.py +++ b/cycode/cli/apps/scan/commit_history/commit_history_command.py @@ -5,8 +5,8 @@ from cycode.cli.apps.scan.code_scanner import scan_commit_range from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception +from cycode.cli.logger import logger from cycode.cli.utils.sentry import add_breadcrumb -from cycode.cyclient import logger def commit_history_command( diff --git a/cycode/cli/apps/scan/path/path_command.py b/cycode/cli/apps/scan/path/path_command.py index 4c841444..48db40ac 100644 --- a/cycode/cli/apps/scan/path/path_command.py +++ b/cycode/cli/apps/scan/path/path_command.py @@ -4,8 +4,8 @@ import typer from cycode.cli.apps.scan.code_scanner import scan_disk_files +from cycode.cli.logger import logger from cycode.cli.utils.sentry import add_breadcrumb -from cycode.cyclient import logger def path_command( diff --git a/cycode/cli/apps/scan/pre_receive/pre_receive_command.py b/cycode/cli/apps/scan/pre_receive/pre_receive_command.py index 92c152e6..01242b24 100644 --- a/cycode/cli/apps/scan/pre_receive/pre_receive_command.py +++ b/cycode/cli/apps/scan/pre_receive/pre_receive_command.py @@ -18,9 +18,9 @@ from cycode.cli.files_collector.repository_documents import ( calculate_pre_receive_commit_range, ) +from cycode.cli.logger import logger from cycode.cli.utils.sentry import add_breadcrumb from cycode.cli.utils.task_timer import TimeoutAfter -from cycode.cyclient import logger def pre_receive_command( diff --git a/cycode/cli/apps/scan/repository/repository_command.py b/cycode/cli/apps/scan/repository/repository_command.py index 0503c237..045448e6 100644 --- a/cycode/cli/apps/scan/repository/repository_command.py +++ b/cycode/cli/apps/scan/repository/repository_command.py @@ -11,11 +11,11 @@ from cycode.cli.files_collector.excluder import exclude_irrelevant_documents_to_scan 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.logger import logger from cycode.cli.models import Document from cycode.cli.utils.path_utils import get_path_by_os from cycode.cli.utils.progress_bar import ScanProgressBarSection from cycode.cli.utils.sentry import add_breadcrumb -from cycode.cyclient import logger def repository_command( diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index dffbf34f..5b9c43c6 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -4,12 +4,9 @@ import typer from cycode.cli.cli_types import ScanTypeOption, ScaScanTypeOption, SeverityOption -from cycode.cli.config import config from cycode.cli.consts import ( ISSUE_DETECTED_STATUS_CODE, NO_ISSUES_STATUS_CODE, - SCA_GRADLE_ALL_SUB_PROJECTS_FLAG, - SCA_SKIP_RESTORE_DEPENDENCIES_FLAG, ) from cycode.cli.utils import scan_utils from cycode.cli.utils.get_api_client import get_scan_cycode_client @@ -82,7 +79,7 @@ def scan_command( no_restore: Annotated[ bool, typer.Option( - f'--{SCA_SKIP_RESTORE_DEPENDENCIES_FLAG}', + '--no-restore', help='When specified, Cycode will not run restore command. ' 'Will scan direct dependencies [bold]only[/bold]!', rich_help_panel='SCA options', @@ -91,7 +88,7 @@ def scan_command( gradle_all_sub_projects: Annotated[ bool, typer.Option( - f'--{SCA_GRADLE_ALL_SUB_PROJECTS_FLAG}', + '--gradle-all-sub-projects', help='When specified, Cycode will run gradle restore command for all sub projects. ' 'Should run from root project directory [bold]only[/bold]!', rich_help_panel='SCA options', @@ -103,24 +100,16 @@ def scan_command( [cyan]path[/cyan]/[cyan]repository[/cyan]/[cyan]commit_history[/cyan].""" add_breadcrumb('scan') - if show_secret: - ctx.obj['show_secret'] = show_secret - else: - ctx.obj['show_secret'] = config['result_printer']['default']['show_secret'] - - if soft_fail: - ctx.obj['soft_fail'] = soft_fail - else: - ctx.obj['soft_fail'] = config['soft_fail'] - + ctx.obj['show_secret'] = show_secret + ctx.obj['soft_fail'] = soft_fail ctx.obj['client'] = get_scan_cycode_client(client_id, client_secret, not ctx.obj['show_secret']) ctx.obj['scan_type'] = scan_type ctx.obj['sync'] = sync ctx.obj['severity_threshold'] = severity_threshold ctx.obj['monitor'] = monitor ctx.obj['report'] = report - ctx.obj[SCA_SKIP_RESTORE_DEPENDENCIES_FLAG] = no_restore - ctx.obj[SCA_GRADLE_ALL_SUB_PROJECTS_FLAG] = gradle_all_sub_projects + + _ = no_restore, gradle_all_sub_projects # they are actually used; via ctx.params _sca_scan_to_context(ctx, sca_scan) diff --git a/cycode/cli/apps/status/get_cli_status.py b/cycode/cli/apps/status/get_cli_status.py index e58e910b..4a3dc4b0 100644 --- a/cycode/cli/apps/status/get_cli_status.py +++ b/cycode/cli/apps/status/get_cli_status.py @@ -4,9 +4,9 @@ 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 +from cycode.cli.logger import logger from cycode.cli.user_settings.configuration_manager import ConfigurationManager from cycode.cli.utils.get_api_client import get_scan_cycode_client -from cycode.cyclient import logger def get_cli_status() -> CliStatus: diff --git a/cycode/cli/config.py b/cycode/cli/config.py index 71f354ad..a1ddbbaf 100644 --- a/cycode/cli/config.py +++ b/cycode/cli/config.py @@ -1,11 +1,5 @@ -import os - from cycode.cli.user_settings.configuration_manager import ConfigurationManager -from cycode.cli.utils.yaml_utils import read_file -relative_path = os.path.dirname(__file__) -config_file_path = os.path.join(relative_path, 'config.yaml') -config = read_file(config_file_path) configuration_manager = ConfigurationManager() # env vars diff --git a/cycode/cli/config.yaml b/cycode/cli/config.yaml deleted file mode 100644 index 875f37c1..00000000 --- a/cycode/cli/config.yaml +++ /dev/null @@ -1,25 +0,0 @@ -soft_fail: False -scans: - supported_scans: - - secret - - iac - - sca - - sast - supported_sca_scans: - - package-vulnerabilities - - license-compliance - supported_sbom_formats: - - spdx-2.2 - - spdx-2.3 - - cyclonedx-1.4 -result_printer: - default: - lines_to_display: 3 - show_secret: False - secret: - pre_receive: - lines_to_display: 1 - show_secret: False - commit_history: - lines_to_display: 1 - show_secret: False \ No newline at end of file diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 1bca08db..60953143 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -223,7 +223,3 @@ # Example: A -> B -> C # Result: A -> ... -> C SCA_SHORTCUT_DEPENDENCY_PATHS = 2 - -SCA_SKIP_RESTORE_DEPENDENCIES_FLAG = 'no-restore' - -SCA_GRADLE_ALL_SUB_PROJECTS_FLAG = 'gradle-all-sub-projects' diff --git a/cycode/cli/files_collector/excluder.py b/cycode/cli/files_collector/excluder.py index 2d8243cb..f16e9710 100644 --- a/cycode/cli/files_collector/excluder.py +++ b/cycode/cli/files_collector/excluder.py @@ -5,13 +5,16 @@ from cycode.cli.user_settings.config_file_manager import ConfigFileManager from cycode.cli.utils.path_utils import get_file_size, is_binary_file, is_sub_path from cycode.cli.utils.string_utils import get_content_size, is_binary_content -from cycode.cyclient import logger +from cycode.logger import get_logger if TYPE_CHECKING: from cycode.cli.models import Document from cycode.cli.utils.progress_bar import BaseProgressBar, ProgressBarSection +logger = get_logger('File Excluder') + + def exclude_irrelevant_files( progress_bar: 'BaseProgressBar', progress_bar_section: 'ProgressBarSection', scan_type: str, filenames: List[str] ) -> List[str]: diff --git a/cycode/cli/files_collector/path_documents.py b/cycode/cli/files_collector/path_documents.py index 571773a0..469e6ce7 100644 --- a/cycode/cli/files_collector/path_documents.py +++ b/cycode/cli/files_collector/path_documents.py @@ -9,9 +9,9 @@ is_tfplan_file, ) from cycode.cli.files_collector.walk_ignore import walk_ignore +from cycode.cli.logger import logger from cycode.cli.models import Document from cycode.cli.utils.path_utils import get_absolute_path, get_file_content -from cycode.cyclient import logger if TYPE_CHECKING: from cycode.cli.utils.progress_bar import BaseProgressBar, ProgressBarSection diff --git a/cycode/cli/files_collector/sca/base_restore_dependencies.py b/cycode/cli/files_collector/sca/base_restore_dependencies.py index b1bf9e80..2e6c0993 100644 --- a/cycode/cli/files_collector/sca/base_restore_dependencies.py +++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py @@ -3,10 +3,10 @@ import typer +from cycode.cli.logger import logger from cycode.cli.models import Document from cycode.cli.utils.path_utils import get_file_content, get_file_dir, get_path_from_context, join_paths from cycode.cli.utils.shell_executor import shell -from cycode.cyclient import logger def build_dep_tree_path(path: str, generated_file_name: str) -> str: diff --git a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py index 77af2a57..5d56644a 100644 --- a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py +++ b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py @@ -1,10 +1,10 @@ -import logging import os from typing import List, Optional import typer from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies +from cycode.cli.logger import logger from cycode.cli.models import Document GO_PROJECT_FILE_EXTENSIONS = ['.mod', '.sum'] @@ -22,7 +22,7 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: lock_exists = os.path.isfile(self.get_working_directory(document) + os.sep + BUILD_GO_LOCK_FILE_NAME) if not manifest_exists or not lock_exists: - logging.info('No manifest go.mod file found' if not manifest_exists else 'No manifest go.sum file found') + logger.info('No manifest go.mod file found' if not manifest_exists else 'No manifest go.sum file found') manifest_files_exists = manifest_exists & lock_exists diff --git a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py index a5c6d48b..3995da90 100644 --- a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py @@ -4,7 +4,6 @@ import typer -from cycode.cli.consts import SCA_GRADLE_ALL_SUB_PROJECTS_FLAG from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies from cycode.cli.models import Document from cycode.cli.utils.path_utils import get_path_from_context @@ -28,7 +27,7 @@ def __init__( self.projects = self.get_all_projects() if self.is_gradle_sub_projects() else projects def is_gradle_sub_projects(self) -> bool: - return self.ctx.obj.get(SCA_GRADLE_ALL_SUB_PROJECTS_FLAG) + return self.ctx.params.get('gradle-all-sub-projects', False) def is_project(self, document: Document) -> bool: return document.path.endswith(BUILD_GRADLE_FILE_NAME) or document.path.endswith(BUILD_GRADLE_KTS_FILE_NAME) diff --git a/cycode/cli/files_collector/sca/sca_code_scanner.py b/cycode/cli/files_collector/sca/sca_code_scanner.py index 3f54eb12..fc8c3809 100644 --- a/cycode/cli/files_collector/sca/sca_code_scanner.py +++ b/cycode/cli/files_collector/sca/sca_code_scanner.py @@ -15,7 +15,7 @@ from cycode.cli.models import Document from cycode.cli.utils.git_proxy import git_proxy from cycode.cli.utils.path_utils import get_file_content, get_file_dir, get_path_from_context, join_paths -from cycode.cyclient import logger +from cycode.logger import get_logger if TYPE_CHECKING: from git import Repo @@ -23,6 +23,9 @@ BUILD_DEP_TREE_TIMEOUT = 180 +logger = get_logger('SCA Code Scanner') + + def perform_pre_commit_range_scan_actions( path: str, from_commit_documents: List[Document], @@ -157,6 +160,7 @@ def get_file_content_from_commit(repo: 'Repo', commit: str, file_path: str) -> O def perform_pre_scan_documents_actions( ctx: typer.Context, scan_type: str, documents_to_scan: List[Document], is_git_diff: bool = False ) -> None: - if scan_type == consts.SCA_SCAN_TYPE and not ctx.obj.get(consts.SCA_SKIP_RESTORE_DEPENDENCIES_FLAG): + no_restore = ctx.params.get('no-restore', False) + if scan_type == consts.SCA_SCAN_TYPE and not no_restore: logger.debug('Perform pre-scan document add_dependencies_tree_document action') add_dependencies_tree_document(ctx, documents_to_scan, is_git_diff) diff --git a/cycode/cli/files_collector/walk_ignore.py b/cycode/cli/files_collector/walk_ignore.py index 93286c87..0ba2b93d 100644 --- a/cycode/cli/files_collector/walk_ignore.py +++ b/cycode/cli/files_collector/walk_ignore.py @@ -1,8 +1,8 @@ import os from typing import Generator, Iterable, List, Tuple +from cycode.cli.logger import logger from cycode.cli.utils.ignore_utils import IgnoreFilterManager -from cycode.cyclient import logger _SUPPORTED_IGNORE_PATTERN_FILES = { # oneday we will bring .cycodeignore or something like that '.gitignore', diff --git a/cycode/cli/files_collector/zip_documents.py b/cycode/cli/files_collector/zip_documents.py index 9547f7fb..b9a272e1 100644 --- a/cycode/cli/files_collector/zip_documents.py +++ b/cycode/cli/files_collector/zip_documents.py @@ -6,7 +6,9 @@ from cycode.cli.exceptions import custom_exceptions from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip from cycode.cli.models import Document -from cycode.cyclient import logger +from cycode.logger import get_logger + +logger = get_logger('ZIP') def _validate_zip_file_size(scan_type: str, zip_file_size: int) -> None: @@ -25,7 +27,7 @@ def zip_documents(scan_type: str, documents: List[Document], zip_file: Optional[ _validate_zip_file_size(scan_type, zip_file.size) logger.debug( - 'Adding file to ZIP, %s', + 'Adding file, %s', {'index': index, 'filename': document.path, 'unique_id': document.unique_id}, ) zip_file.append(document.path, document.unique_id, document.content) @@ -34,11 +36,14 @@ def zip_documents(scan_type: str, documents: List[Document], zip_file: Optional[ end_zip_creation_time = timeit.default_timer() zip_creation_time = int(end_zip_creation_time - start_zip_creation_time) - logger.debug('Finished to create ZIP file, %s', {'zip_creation_time': zip_creation_time}) + logger.debug( + 'Finished to create file, %s', + {'zip_creation_time': zip_creation_time, 'zip_size': zip_file.size, 'documents_count': len(documents)}, + ) if zip_file.configuration_manager.get_debug_flag(): zip_file_path = Path.joinpath(Path.cwd(), f'{scan_type}_scan_{end_zip_creation_time}.zip') - logger.debug('Writing ZIP file to disk, %s', {'zip_file_path': zip_file_path}) + logger.debug('Writing file to disk, %s', {'zip_file_path': zip_file_path}) zip_file.write_on_disk(zip_file_path) return zip_file diff --git a/cycode/cli/logger.py b/cycode/cli/logger.py new file mode 100644 index 00000000..46748bff --- /dev/null +++ b/cycode/cli/logger.py @@ -0,0 +1,3 @@ +from cycode.logger import get_logger + +logger = get_logger('CLI') diff --git a/cycode/cli/printers/printer_base.py b/cycode/cli/printers/printer_base.py index 45419aec..ee9a7793 100644 --- a/cycode/cli/printers/printer_base.py +++ b/cycode/cli/printers/printer_base.py @@ -1,8 +1,7 @@ -import traceback +import sys from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Dict, List, Optional -import click import typer from cycode.cli.models import CliError, CliResult @@ -12,6 +11,10 @@ from cycode.cli.models import LocalScanResult +from rich.console import Console +from rich.traceback import Traceback + + class PrinterBase(ABC): RED_COLOR_NAME = 'red' WHITE_COLOR_NAME = 'white' @@ -40,14 +43,9 @@ def print_exception(self, e: Optional[BaseException] = None) -> None: Note: Called only when the verbose flag is set. """ - if e is None: - # gets the most recent exception caught by an except clause - message = f'Error: {traceback.format_exc()}' - else: - traceback_message = ''.join(traceback.format_exception(None, e, e.__traceback__)) - message = f'Error: {traceback_message}' + console = Console(stderr=True) - click.secho(message, err=True, fg=self.RED_COLOR_NAME) + traceback = Traceback.from_exception(type(e), e, None) if e else Traceback.from_exception(*sys.exc_info()) + console.print(traceback) - correlation_message = f'Correlation ID: {get_correlation_id()}' - click.secho(correlation_message, err=True, fg=self.RED_COLOR_NAME) + console.print(f'Correlation ID: {get_correlation_id()}', style=self.RED_COLOR_NAME) diff --git a/cycode/cli/printers/text_printer.py b/cycode/cli/printers/text_printer.py index 0f617f8c..7828d909 100644 --- a/cycode/cli/printers/text_printer.py +++ b/cycode/cli/printers/text_printer.py @@ -4,7 +4,6 @@ import click import typer -from cycode.cli.config import config from cycode.cli.consts import COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES, SECRET_SCAN_TYPE from cycode.cli.models import CliError, CliResult, Detection, Document, DocumentDetections from cycode.cli.printers.printer_base import PrinterBase @@ -17,7 +16,7 @@ class TextPrinter(PrinterBase): def __init__(self, ctx: typer.Context) -> None: super().__init__(ctx) - self.scan_type: str = ctx.obj.get('scan_type') + self.scan_type = ctx.obj.get('scan_type') self.command_scan_type: str = ctx.info_name self.show_secret: bool = ctx.obj.get('show_secret', False) @@ -66,10 +65,9 @@ def print_scan_results( def _print_document_detections(self, document_detections: DocumentDetections, scan_id: str) -> None: document = document_detections.document - lines_to_display = self._get_lines_to_display_count() for detection in document_detections.detections: self._print_detection_summary(detection, document.path, scan_id) - self._print_detection_code_segment(detection, document, lines_to_display) + self._print_detection_code_segment(detection, document) def _print_detection_summary(self, detection: Detection, document_path: str, scan_id: str) -> None: detection_name = detection.type if self.scan_type == SECRET_SCAN_TYPE else detection.message @@ -96,12 +94,15 @@ def _print_detection_summary(self, detection: Detection, document_path: str, sca f' ⛔' ) - def _print_detection_code_segment(self, detection: Detection, document: Document, code_segment_size: int) -> None: + def _print_detection_code_segment( + self, detection: Detection, document: Document, lines_to_display: int = 3 + ) -> None: if self._is_git_diff_based_scan(): + # it will print just one line self._print_detection_from_git_diff(detection, document) return - self._print_detection_from_file(detection, document, code_segment_size) + self._print_detection_from_file(detection, document, lines_to_display) @staticmethod def _print_report_urls(report_urls: List[str], aggregation_report_url: Optional[str] = None) -> None: @@ -116,8 +117,8 @@ def _print_report_urls(report_urls: List[str], aggregation_report_url: Optional[ click.echo(f'- {report_url}') @staticmethod - def _get_code_segment_start_line(detection_line: int, code_segment_size: int) -> int: - start_line = detection_line - math.ceil(code_segment_size / 2) + def _get_code_segment_start_line(detection_line: int, lines_to_display: int) -> int: + start_line = detection_line - math.ceil(lines_to_display / 2) return 0 if start_line < 0 else start_line def _print_line_of_code_segment( @@ -193,17 +194,7 @@ def _get_line_number_style(self, line_number: int) -> str: f'{click.style("|", fg=self.RED_COLOR_NAME, bold=False)}' ) - def _get_lines_to_display_count(self) -> int: - result_printer_configuration = config.get('result_printer') - lines_to_display_of_scan = ( - result_printer_configuration.get(self.scan_type, {}).get(self.command_scan_type, {}).get('lines_to_display') - ) - if lines_to_display_of_scan: - return lines_to_display_of_scan - - return result_printer_configuration.get('default').get('lines_to_display') - - def _print_detection_from_file(self, detection: Detection, document: Document, code_segment_size: int) -> None: + def _print_detection_from_file(self, detection: Detection, document: Document, lines_to_display: int) -> None: detection_details = detection.detection_details detection_line = ( detection_details.get('line', -1) @@ -215,12 +206,12 @@ def _print_detection_from_file(self, detection: Detection, document: Document, c file_content = document.content file_lines = file_content.splitlines() - start_line = self._get_code_segment_start_line(detection_line, code_segment_size) + start_line = self._get_code_segment_start_line(detection_line, lines_to_display) detection_position_in_line = get_position_in_line(file_content, detection_position) click.echo() - for i in range(code_segment_size): - current_line_index = start_line + i + for line_index in range(lines_to_display): + current_line_index = start_line + line_index if current_line_index >= len(file_lines): break diff --git a/cycode/cli/user_settings/base_file_manager.py b/cycode/cli/user_settings/base_file_manager.py index b7be273d..4eb15e2a 100644 --- a/cycode/cli/user_settings/base_file_manager.py +++ b/cycode/cli/user_settings/base_file_manager.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod from typing import Any, Dict, Hashable -from cycode.cli.utils.yaml_utils import read_file, update_file +from cycode.cli.utils.yaml_utils import read_yaml_file, update_yaml_file class BaseFileManager(ABC): @@ -10,9 +10,9 @@ class BaseFileManager(ABC): def get_filename(self) -> str: ... def read_file(self) -> Dict[Hashable, Any]: - return read_file(self.get_filename()) + return read_yaml_file(self.get_filename()) def write_content_to_file(self, content: Dict[Hashable, Any]) -> None: filename = self.get_filename() os.makedirs(os.path.dirname(filename), exist_ok=True) - update_file(filename, content) + update_yaml_file(filename, content) diff --git a/cycode/cli/utils/path_utils.py b/cycode/cli/utils/path_utils.py index c6d2001d..3f670dd4 100644 --- a/cycode/cli/utils/path_utils.py +++ b/cycode/cli/utils/path_utils.py @@ -6,7 +6,7 @@ import typer from binaryornot.helpers import is_binary_string -from cycode.cyclient import logger +from cycode.cli.logger import logger if TYPE_CHECKING: from os import PathLike diff --git a/cycode/cli/utils/progress_bar.py b/cycode/cli/utils/progress_bar.py index 623222d7..3d798131 100644 --- a/cycode/cli/utils/progress_bar.py +++ b/cycode/cli/utils/progress_bar.py @@ -5,14 +5,14 @@ import click from cycode.cli.utils.enum_utils import AutoCountEnum -from cycode.cyclient.config import get_logger +from cycode.logger import get_logger if TYPE_CHECKING: from click._termui_impl import ProgressBar from click.termui import V as ProgressBarValue # use LOGGING_LEVEL=DEBUG env var to see debug logs of this module -logger = get_logger('progress bar', control_level_in_runtime=False) +logger = get_logger('Progress Bar', control_level_in_runtime=False) class ProgressBarSection(AutoCountEnum): diff --git a/cycode/cli/utils/scan_batch.py b/cycode/cli/utils/scan_batch.py index 4019b7b0..45e4d120 100644 --- a/cycode/cli/utils/scan_batch.py +++ b/cycode/cli/utils/scan_batch.py @@ -5,13 +5,16 @@ from cycode.cli import consts from cycode.cli.models import Document from cycode.cli.utils.progress_bar import ScanProgressBarSection -from cycode.cyclient import logger +from cycode.logger import get_logger if TYPE_CHECKING: from cycode.cli.models import CliError, LocalScanResult from cycode.cli.utils.progress_bar import BaseProgressBar +logger = get_logger('Batching') + + def _get_max_batch_size(scan_type: str) -> int: logger.debug( 'You can customize the batch size by setting the environment variable "%s"', diff --git a/cycode/cli/utils/sentry.py b/cycode/cli/utils/sentry.py index e132bcf8..16b2a982 100644 --- a/cycode/cli/utils/sentry.py +++ b/cycode/cli/utils/sentry.py @@ -11,8 +11,8 @@ from cycode import __version__ from cycode.cli import consts +from cycode.cli.logger import logger from cycode.cli.utils.jwt_utils import get_user_and_tenant_ids_from_access_token -from cycode.cyclient import logger from cycode.cyclient.config import on_premise_installation # when Sentry is blocked on the machine, we want to keep clean output without retries warnings diff --git a/cycode/cli/utils/shell_executor.py b/cycode/cli/utils/shell_executor.py index 5ac79518..a7a537e6 100644 --- a/cycode/cli/utils/shell_executor.py +++ b/cycode/cli/utils/shell_executor.py @@ -3,11 +3,14 @@ import click -from cycode.cyclient import logger +from cycode.logger import get_logger _SUBPROCESS_DEFAULT_TIMEOUT_SEC = 60 +logger = get_logger('SHELL') + + def shell( command: Union[str, List[str]], timeout: int = _SUBPROCESS_DEFAULT_TIMEOUT_SEC, @@ -19,13 +22,16 @@ def shell( result = subprocess.run( # noqa: S603 command, cwd=working_directory, timeout=timeout, check=True, capture_output=True ) + logger.debug('Shell command executed successfully') return result.stdout.decode('UTF-8').strip() except subprocess.CalledProcessError as e: logger.debug('Error occurred while running shell command', exc_info=e) except subprocess.TimeoutExpired as e: + logger.debug('Command timed out', exc_info=e) raise click.Abort(f'Command "{command}" timed out') from e except Exception as e: + logger.debug('Unhandled exception occurred while running shell command', exc_info=e) raise click.ClickException(f'Unhandled exception: {e}') from e return None diff --git a/cycode/cli/utils/yaml_utils.py b/cycode/cli/utils/yaml_utils.py index 251b6c24..388f3498 100644 --- a/cycode/cli/utils/yaml_utils.py +++ b/cycode/cli/utils/yaml_utils.py @@ -4,6 +4,16 @@ import yaml +def _deep_update(source: Dict[Hashable, Any], overrides: Dict[Hashable, Any]) -> Dict[Hashable, Any]: + for key, value in overrides.items(): + if isinstance(value, dict) and value: + source[key] = _deep_update(source.get(key, {}), value) + else: + source[key] = overrides[key] + + return source + + def _yaml_safe_load(file: TextIO) -> Dict[Hashable, Any]: # loader.get_single_data could return None loaded_file = yaml.safe_load(file) @@ -13,7 +23,7 @@ def _yaml_safe_load(file: TextIO) -> Dict[Hashable, Any]: return loaded_file -def read_file(filename: str) -> Dict[Hashable, Any]: +def read_yaml_file(filename: str) -> Dict[Hashable, Any]: if not os.path.exists(filename): return {} @@ -21,20 +31,10 @@ def read_file(filename: str) -> Dict[Hashable, Any]: return _yaml_safe_load(file) -def write_file(filename: str, content: Dict[Hashable, Any]) -> None: +def write_yaml_file(filename: str, content: Dict[Hashable, Any]) -> None: with open(filename, 'w', encoding='UTF-8') as file: yaml.safe_dump(content, file) -def update_file(filename: str, content: Dict[Hashable, Any]) -> None: - write_file(filename, _deep_update(read_file(filename), content)) - - -def _deep_update(source: Dict[Hashable, Any], overrides: Dict[Hashable, Any]) -> Dict[Hashable, Any]: - for key, value in overrides.items(): - if isinstance(value, dict) and value: - source[key] = _deep_update(source.get(key, {}), value) - else: - source[key] = overrides[key] - - return source +def update_yaml_file(filename: str, content: Dict[Hashable, Any]) -> None: + write_yaml_file(filename, _deep_update(read_yaml_file(filename), content)) diff --git a/cycode/config.py b/cycode/config.py new file mode 100644 index 00000000..f4306b31 --- /dev/null +++ b/cycode/config.py @@ -0,0 +1,45 @@ +import logging +import os +from typing import Optional +from urllib.parse import urlparse + +from cycode.cli import consts +from cycode.cyclient import config_dev + +DEFAULT_CONFIGURATION = { + consts.TIMEOUT_ENV_VAR_NAME: 300, + consts.LOGGING_LEVEL_ENV_VAR_NAME: logging.INFO, + config_dev.DEV_MODE_ENV_VAR_NAME: 'false', +} + +configuration = dict(DEFAULT_CONFIGURATION, **os.environ) + + +def get_val_as_string(key: str) -> str: + return configuration.get(key) + + +def get_val_as_bool(key: str, default: bool = False) -> bool: + if key not in configuration: + return default + + return configuration[key].lower() in {'true', '1', 'yes', 'y', 'on', 'enabled'} + + +def get_val_as_int(key: str) -> Optional[int]: + val = configuration.get(key) + if not val: + return None + + try: + return int(val) + except ValueError: + return None + + +def is_valid_url(url: str) -> bool: + try: + parsed_url = urlparse(url) + return all([parsed_url.scheme, parsed_url.netloc]) + except ValueError: + return False diff --git a/cycode/cyclient/config.py b/cycode/cyclient/config.py index 37183195..2b278bf4 100644 --- a/cycode/cyclient/config.py +++ b/cycode/cyclient/config.py @@ -1,102 +1,9 @@ -import logging -import os -import sys -from typing import NamedTuple, Optional, Set, Union -from urllib.parse import urlparse - from cycode.cli import consts from cycode.cli.user_settings.configuration_manager import ConfigurationManager +from cycode.config import get_val_as_bool, get_val_as_int, get_val_as_string, is_valid_url from cycode.cyclient import config_dev +from cycode.cyclient.logger import logger - -def _set_io_encodings() -> None: - # set io encoding (for Windows) - sys.stdout.reconfigure(encoding='UTF-8') - sys.stderr.reconfigure(encoding='UTF-8') - - -_set_io_encodings() - -# logs -logging.basicConfig( - stream=sys.stderr, - level=logging.INFO, - format='%(asctime)s [%(name)s] %(levelname)s: %(message)s', - datefmt='%Y-%m-%d %H:%M:%S', -) -logging.getLogger('urllib3').setLevel(logging.WARNING) -logging.getLogger('werkzeug').setLevel(logging.WARNING) -logging.getLogger('schedule').setLevel(logging.WARNING) -logging.getLogger('kubernetes').setLevel(logging.WARNING) -logging.getLogger('binaryornot').setLevel(logging.WARNING) -logging.getLogger('chardet').setLevel(logging.WARNING) -logging.getLogger('git.cmd').setLevel(logging.WARNING) -logging.getLogger('git.util').setLevel(logging.WARNING) - -# configs -DEFAULT_CONFIGURATION = { - consts.TIMEOUT_ENV_VAR_NAME: 300, - consts.LOGGING_LEVEL_ENV_VAR_NAME: logging.INFO, - config_dev.DEV_MODE_ENV_VAR_NAME: 'false', -} - -configuration = dict(DEFAULT_CONFIGURATION, **os.environ) - - -class CreatedLogger(NamedTuple): - logger: logging.Logger - control_level_in_runtime: bool - - -_CREATED_LOGGERS: Set[CreatedLogger] = set() - - -def get_logger_level() -> Optional[Union[int, str]]: - config_level = get_val_as_string(consts.LOGGING_LEVEL_ENV_VAR_NAME) - return logging.getLevelName(config_level) - - -def get_logger(logger_name: Optional[str] = None, control_level_in_runtime: bool = True) -> logging.Logger: - new_logger = logging.getLogger(logger_name) - new_logger.setLevel(get_logger_level()) - - _CREATED_LOGGERS.add(CreatedLogger(logger=new_logger, control_level_in_runtime=control_level_in_runtime)) - - return new_logger - - -def set_logging_level(level: int) -> None: - for created_logger in _CREATED_LOGGERS: - if created_logger.control_level_in_runtime: - created_logger.logger.setLevel(level) - - -def get_val_as_string(key: str) -> str: - return configuration.get(key) - - -def get_val_as_bool(key: str, default: str = '') -> bool: - val = configuration.get(key, default) - return val.lower() in {'true', '1'} - - -def get_val_as_int(key: str) -> Optional[int]: - val = configuration.get(key) - if val: - return int(val) - - return None - - -def is_valid_url(url: str) -> bool: - try: - parsed_url = urlparse(url) - return all([parsed_url.scheme, parsed_url.netloc]) - except ValueError: - return False - - -logger = get_logger('cycode cli') configuration_manager = ConfigurationManager() cycode_api_url = configuration_manager.get_cycode_api_url() diff --git a/cycode/cyclient/config.yaml b/cycode/cyclient/config.yaml deleted file mode 100644 index 7b8c1bdc..00000000 --- a/cycode/cyclient/config.yaml +++ /dev/null @@ -1,5 +0,0 @@ -cycode: - api: - base_url: http://api.cycode.com - time_out: 30 -# base_url: http://localhost:5048 #local configuration diff --git a/cycode/cyclient/logger.py b/cycode/cyclient/logger.py new file mode 100644 index 00000000..b36f036f --- /dev/null +++ b/cycode/cyclient/logger.py @@ -0,0 +1,3 @@ +from cycode.logger import get_logger + +logger = get_logger('CyClient') diff --git a/cycode/logger.py b/cycode/logger.py new file mode 100644 index 00000000..684d296c --- /dev/null +++ b/cycode/logger.py @@ -0,0 +1,66 @@ +import logging +import sys +from typing import NamedTuple, Optional, Set, Union + +import click +import typer +from rich.console import Console +from rich.logging import RichHandler + +from cycode.cli import consts +from cycode.config import get_val_as_string + + +def _set_io_encodings() -> None: + # set io encoding (for Windows) + sys.stdout.reconfigure(encoding='UTF-8') + sys.stderr.reconfigure(encoding='UTF-8') + + +_set_io_encodings() + +_ERROR_CONSOLE = Console(stderr=True) +_RICH_LOGGING_HANDLER = RichHandler(console=_ERROR_CONSOLE, rich_tracebacks=True, tracebacks_suppress=[click, typer]) + +logging.basicConfig( + level=logging.INFO, + format='[%(name)s] %(message)s', + handlers=[_RICH_LOGGING_HANDLER], +) + +logging.getLogger('urllib3').setLevel(logging.WARNING) +logging.getLogger('werkzeug').setLevel(logging.WARNING) +logging.getLogger('schedule').setLevel(logging.WARNING) +logging.getLogger('kubernetes').setLevel(logging.WARNING) +logging.getLogger('binaryornot').setLevel(logging.WARNING) +logging.getLogger('chardet').setLevel(logging.WARNING) +logging.getLogger('git.cmd').setLevel(logging.WARNING) +logging.getLogger('git.util').setLevel(logging.WARNING) + + +class CreatedLogger(NamedTuple): + logger: logging.Logger + control_level_in_runtime: bool + + +_CREATED_LOGGERS: Set[CreatedLogger] = set() + + +def get_logger_level() -> Optional[Union[int, str]]: + config_level = get_val_as_string(consts.LOGGING_LEVEL_ENV_VAR_NAME) + return logging.getLevelName(config_level) + + +def get_logger(logger_name: Optional[str] = None, control_level_in_runtime: bool = True) -> logging.Logger: + new_logger = logging.getLogger(logger_name) + new_logger.setLevel(get_logger_level()) + + _CREATED_LOGGERS.add(CreatedLogger(logger=new_logger, control_level_in_runtime=control_level_in_runtime)) + + return new_logger + + +def set_logging_level(level: int) -> None: + for created_logger in _CREATED_LOGGERS: + if created_logger.control_level_in_runtime: + created_logger.logger.setLevel(level) diff --git a/pyinstaller.spec b/pyinstaller.spec index cb3382d4..39b8588f 100644 --- a/pyinstaller.spec +++ b/pyinstaller.spec @@ -23,7 +23,6 @@ with open(_INIT_FILE_PATH, 'w', encoding='UTF-8') as file: a = Analysis( scripts=['cycode/cli/main.py'], - datas=[('cycode/cli/config.yaml', 'cycode/cli'), ('cycode/cyclient/config.yaml', 'cycode/cyclient')], excludes=['tests'], ) diff --git a/tests/cyclient/test_scan_client.py b/tests/cyclient/test_scan_client.py index 1b1724ca..a1c0d151 100644 --- a/tests/cyclient/test_scan_client.py +++ b/tests/cyclient/test_scan_client.py @@ -9,7 +9,7 @@ from requests.exceptions import ProxyError from cycode.cli import consts -from cycode.cli.config import config +from cycode.cli.cli_types import ScanTypeOption from cycode.cli.exceptions.custom_exceptions import ( CycodeError, HttpUnauthorizedError, @@ -29,14 +29,14 @@ ) -def zip_scan_resources(scan_type: str, scan_client: ScanClient) -> Tuple[str, InMemoryZip]: +def zip_scan_resources(scan_type: ScanTypeOption, scan_client: ScanClient) -> Tuple[str, InMemoryZip]: url = get_zipped_file_scan_url(scan_type, scan_client) zip_file = get_test_zip_file(scan_type) return url, zip_file -def get_test_zip_file(scan_type: str) -> InMemoryZip: +def get_test_zip_file(scan_type: ScanTypeOption) -> InMemoryZip: # TODO(MarshalX): refactor scan_disk_files in code_scanner.py to reuse method here instead of this test_documents: List[Document] = [] for root, _, files in os.walk(ZIP_CONTENT_PATH): @@ -56,9 +56,11 @@ def test_get_service_name(scan_client: ScanClient) -> None: assert scan_client.get_service_name(consts.SAST_SCAN_TYPE) == 'scans' -@pytest.mark.parametrize('scan_type', config['scans']['supported_scans']) +@pytest.mark.parametrize('scan_type', list(ScanTypeOption)) @responses.activate -def test_zipped_file_scan(scan_type: str, scan_client: ScanClient, api_token_response: responses.Response) -> None: +def test_zipped_file_scan( + scan_type: ScanTypeOption, scan_client: ScanClient, api_token_response: responses.Response +) -> None: url, zip_file = zip_scan_resources(scan_type, scan_client) expected_scan_id = uuid4() @@ -71,9 +73,11 @@ def test_zipped_file_scan(scan_type: str, scan_client: ScanClient, api_token_res assert zipped_file_scan_response.scan_id == str(expected_scan_id) -@pytest.mark.parametrize('scan_type', config['scans']['supported_scans']) +@pytest.mark.parametrize('scan_type', list(ScanTypeOption)) @responses.activate -def test_get_scan_report_url(scan_type: str, scan_client: ScanClient, api_token_response: responses.Response) -> None: +def test_get_scan_report_url( + scan_type: ScanTypeOption, scan_client: ScanClient, api_token_response: responses.Response +) -> None: scan_id = uuid4() url = get_scan_report_url(scan_id, scan_client, scan_type) @@ -84,10 +88,10 @@ def test_get_scan_report_url(scan_type: str, scan_client: ScanClient, api_token_ assert scan_report_url_response.report_url == 'https://app.domain/on-demand-scans/{scan_id}'.format(scan_id=scan_id) -@pytest.mark.parametrize('scan_type', config['scans']['supported_scans']) +@pytest.mark.parametrize('scan_type', list(ScanTypeOption)) @responses.activate def test_zipped_file_scan_unauthorized_error( - scan_type: str, scan_client: ScanClient, api_token_response: responses.Response + scan_type: ScanTypeOption, scan_client: ScanClient, api_token_response: responses.Response ) -> None: url, zip_file = zip_scan_resources(scan_type, scan_client) expected_scan_id = uuid4().hex @@ -101,10 +105,10 @@ def test_zipped_file_scan_unauthorized_error( assert e_info.value.status_code == 401 -@pytest.mark.parametrize('scan_type', config['scans']['supported_scans']) +@pytest.mark.parametrize('scan_type', list(ScanTypeOption)) @responses.activate def test_zipped_file_scan_bad_request_error( - scan_type: str, scan_client: ScanClient, api_token_response: responses.Response + scan_type: ScanTypeOption, scan_client: ScanClient, api_token_response: responses.Response ) -> None: url, zip_file = zip_scan_resources(scan_type, scan_client) expected_scan_id = uuid4().hex @@ -122,10 +126,10 @@ def test_zipped_file_scan_bad_request_error( assert e_info.value.error_message == expected_response_text -@pytest.mark.parametrize('scan_type', config['scans']['supported_scans']) +@pytest.mark.parametrize('scan_type', list(ScanTypeOption)) @responses.activate def test_zipped_file_scan_timeout_error( - scan_type: str, scan_client: ScanClient, api_token_response: responses.Response + scan_type: ScanTypeOption, scan_client: ScanClient, api_token_response: responses.Response ) -> None: scan_url, zip_file = zip_scan_resources(scan_type, scan_client) expected_scan_id = uuid4().hex @@ -148,10 +152,10 @@ def test_zipped_file_scan_timeout_error( scan_client.zipped_file_scan(scan_type, zip_file, scan_id=expected_scan_id, scan_parameters={}) -@pytest.mark.parametrize('scan_type', config['scans']['supported_scans']) +@pytest.mark.parametrize('scan_type', list(ScanTypeOption)) @responses.activate def test_zipped_file_scan_connection_error( - scan_type: str, scan_client: ScanClient, api_token_response: responses.Response + scan_type: ScanTypeOption, scan_client: ScanClient, api_token_response: responses.Response ) -> None: url, zip_file = zip_scan_resources(scan_type, scan_client) expected_scan_id = uuid4().hex diff --git a/tests/test_code_scanner.py b/tests/test_code_scanner.py index d6341a7c..709fe70e 100644 --- a/tests/test_code_scanner.py +++ b/tests/test_code_scanner.py @@ -9,7 +9,7 @@ _try_get_aggregation_report_url_if_needed, _try_get_report_url_if_needed, ) -from cycode.cli.config import config +from cycode.cli.cli_types import ScanTypeOption from cycode.cli.files_collector.excluder import _is_relevant_file_to_scan from cycode.cyclient.scan_client import ScanClient from tests.conftest import TEST_FILES_PATH @@ -26,17 +26,17 @@ def test_is_relevant_file_to_scan_sca() -> None: assert _is_relevant_file_to_scan(consts.SCA_SCAN_TYPE, path) is True -@pytest.mark.parametrize('scan_type', config['scans']['supported_scans']) -def test_try_get_report_url_if_needed_return_none(scan_type: str, scan_client: ScanClient) -> None: +@pytest.mark.parametrize('scan_type', list(ScanTypeOption)) +def test_try_get_report_url_if_needed_return_none(scan_type: ScanTypeOption, scan_client: ScanClient) -> None: scan_id = uuid4().hex result = _try_get_report_url_if_needed(scan_client, False, scan_id, consts.SECRET_SCAN_TYPE) assert result is None -@pytest.mark.parametrize('scan_type', config['scans']['supported_scans']) +@pytest.mark.parametrize('scan_type', list(ScanTypeOption)) @responses.activate def test_try_get_report_url_if_needed_return_result( - scan_type: str, scan_client: ScanClient, api_token_response: responses.Response + scan_type: ScanTypeOption, scan_client: ScanClient, api_token_response: responses.Response ) -> None: scan_id = uuid4() url = get_scan_report_url(scan_id, scan_client, scan_type) @@ -48,9 +48,9 @@ def test_try_get_report_url_if_needed_return_result( assert result == scan_report_url_response.report_url -@pytest.mark.parametrize('scan_type', config['scans']['supported_scans']) +@pytest.mark.parametrize('scan_type', list(ScanTypeOption)) def test_try_get_aggregation_report_url_if_no_report_command_needed_return_none( - scan_type: str, scan_client: ScanClient + scan_type: ScanTypeOption, scan_client: ScanClient ) -> None: aggregation_id = uuid4().hex scan_parameter = {'aggregation_id': aggregation_id} @@ -58,19 +58,19 @@ def test_try_get_aggregation_report_url_if_no_report_command_needed_return_none( assert result is None -@pytest.mark.parametrize('scan_type', config['scans']['supported_scans']) +@pytest.mark.parametrize('scan_type', list(ScanTypeOption)) def test_try_get_aggregation_report_url_if_no_aggregation_id_needed_return_none( - scan_type: str, scan_client: ScanClient + scan_type: ScanTypeOption, scan_client: ScanClient ) -> None: scan_parameter = {'report': True} result = _try_get_aggregation_report_url_if_needed(scan_parameter, scan_client, scan_type) assert result is None -@pytest.mark.parametrize('scan_type', config['scans']['supported_scans']) +@pytest.mark.parametrize('scan_type', list(ScanTypeOption)) @responses.activate def test_try_get_aggregation_report_url_if_needed_return_result( - scan_type: str, scan_client: ScanClient, api_token_response: responses.Response + scan_type: ScanTypeOption, scan_client: ScanClient, api_token_response: responses.Response ) -> None: aggregation_id = uuid4() scan_parameter = {'report': True, 'aggregation_id': aggregation_id} From b64c67e2ce6d0ea7e96822ee3d6a9191d89627db Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Fri, 14 Mar 2025 15:55:13 +0100 Subject: [PATCH 148/257] CM-45715 - Add rich progress bar with spinner and elapsed time (#286) --- cycode/cli/apps/report/sbom/common.py | 2 +- cycode/cli/utils/progress_bar.py | 94 ++++++++++++--------------- cycode/cyclient/__init__.py | 5 -- cycode/cyclient/cycode_client_base.py | 3 +- cycode/cyclient/headers.py | 2 +- 5 files changed, 44 insertions(+), 62 deletions(-) diff --git a/cycode/cli/apps/report/sbom/common.py b/cycode/cli/apps/report/sbom/common.py index dabaffef..067a9fa6 100644 --- a/cycode/cli/apps/report/sbom/common.py +++ b/cycode/cli/apps/report/sbom/common.py @@ -30,7 +30,7 @@ def _poll_report_execution_until_completed( report_execution = client.get_report_execution(report_execution_id) report_label = report_execution.error_message or report_execution.status_message - progress_bar.update_label(report_label) + progress_bar.update_right_side_label(report_label) if report_execution.status == consts.REPORT_STATUS_COMPLETED: return report_execution diff --git a/cycode/cli/utils/progress_bar.py b/cycode/cli/utils/progress_bar.py index 3d798131..90a19801 100644 --- a/cycode/cli/utils/progress_bar.py +++ b/cycode/cli/utils/progress_bar.py @@ -1,16 +1,12 @@ from abc import ABC, abstractmethod from enum import auto -from typing import TYPE_CHECKING, Dict, NamedTuple, Optional +from typing import Dict, NamedTuple, Optional -import click +from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn, TimeElapsedColumn from cycode.cli.utils.enum_utils import AutoCountEnum from cycode.logger import get_logger -if TYPE_CHECKING: - from click._termui_impl import ProgressBar - from click.termui import V as ProgressBarValue - # use LOGGING_LEVEL=DEBUG env var to see debug logs of this module logger = get_logger('Progress Bar', control_level_in_runtime=False) @@ -32,6 +28,14 @@ class ProgressBarSectionInfo(NamedTuple): _PROGRESS_BAR_LENGTH = 100 +_PROGRESS_BAR_COLUMNS = ( + SpinnerColumn(), + TextColumn('[progress.description]{task.description}'), + TextColumn('{task.fields[right_side_label]}'), + BarColumn(bar_width=None), + TaskProgressColumn(), + TimeElapsedColumn(), +) ProgressBarSections = Dict[ProgressBarSection, ProgressBarSectionInfo] @@ -91,12 +95,6 @@ class BaseProgressBar(ABC): def __init__(self, *args, **kwargs) -> None: pass - @abstractmethod - def __enter__(self) -> 'BaseProgressBar': ... - - @abstractmethod - def __exit__(self, *args, **kwargs) -> None: ... - @abstractmethod def start(self) -> None: ... @@ -110,19 +108,13 @@ def set_section_length(self, section: 'ProgressBarSection', length: int = 0) -> def update(self, section: 'ProgressBarSection') -> None: ... @abstractmethod - def update_label(self, label: Optional[str] = None) -> None: ... + def update_right_side_label(self, label: Optional[str] = None) -> None: ... class DummyProgressBar(BaseProgressBar): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) - def __enter__(self) -> 'DummyProgressBar': - return self - - def __exit__(self, *args, **kwargs) -> None: - pass - def start(self) -> None: pass @@ -135,7 +127,7 @@ def set_section_length(self, section: 'ProgressBarSection', length: int = 0) -> def update(self, section: 'ProgressBarSection') -> None: pass - def update_label(self, label: Optional[str] = None) -> None: + def update_right_side_label(self, label: Optional[str] = None) -> None: pass @@ -143,38 +135,41 @@ class CompositeProgressBar(BaseProgressBar): def __init__(self, progress_bar_sections: ProgressBarSections) -> None: super().__init__() - self._progress_bar_sections = progress_bar_sections - - self._progress_bar_context_manager = click.progressbar( - length=_PROGRESS_BAR_LENGTH, - item_show_func=self._progress_bar_item_show_func, - update_min_steps=0, - ) - self._progress_bar: Optional['ProgressBar'] = None self._run = False + self._progress_bar_sections = progress_bar_sections self._section_lengths: Dict[ProgressBarSection, int] = {} self._section_values: Dict[ProgressBarSection, int] = {} self._current_section_value = 0 self._current_section: ProgressBarSectionInfo = _get_initial_section(self._progress_bar_sections) + self._current_right_side_label = '' - def __enter__(self) -> 'CompositeProgressBar': - self._progress_bar = self._progress_bar_context_manager.__enter__() - self._run = True - return self + self._progress_bar = Progress( + *_PROGRESS_BAR_COLUMNS, + transient=True, + ) + self._progress_bar_task_id = self._progress_bar.add_task( + description=self._current_section.label, + total=_PROGRESS_BAR_LENGTH, + right_side_label=self._current_right_side_label, + ) - def __exit__(self, *args, **kwargs) -> None: - self._progress_bar_context_manager.__exit__(*args, **kwargs) - self._run = False + def _progress_bar_update(self, advance: int = 0) -> None: + self._progress_bar.update( + self._progress_bar_task_id, + advance=advance, + description=self._current_section.label, + right_side_label=self._current_right_side_label, + ) def start(self) -> None: if not self._run: - self.__enter__() + self._progress_bar.start() def stop(self) -> None: if self._run: - self.__exit__(None, None, None) + self._progress_bar.stop() def set_section_length(self, section: 'ProgressBarSection', length: int = 0) -> None: logger.debug('Calling set_section_length, %s', {'section': str(section), 'length': length}) @@ -190,7 +185,7 @@ def _get_section_length(self, section: 'ProgressBarSection') -> int: return section_info.stop_percent - section_info.start_percent def _skip_section(self, section: 'ProgressBarSection') -> None: - self._progress_bar.update(self._get_section_length(section)) + self._progress_bar_update(self._get_section_length(section)) self._maybe_update_current_section() def _increment_section_value(self, section: 'ProgressBarSection', value: int) -> None: @@ -205,13 +200,13 @@ def _increment_section_value(self, section: 'ProgressBarSection', value: int) -> def _rerender_progress_bar(self) -> None: """Used to update label right after changing the progress bar section.""" - self._progress_bar.update(0) + self._progress_bar_update() def _increment_progress(self, section: 'ProgressBarSection') -> None: increment_value = self._get_increment_progress_value(section) self._current_section_value += increment_value - self._progress_bar.update(increment_value) + self._progress_bar_update(increment_value) def _maybe_update_current_section(self) -> None: if not self._current_section.section.has_next(): @@ -237,13 +232,7 @@ def _get_increment_progress_value(self, section: 'ProgressBarSection') -> int: return expected_value - self._current_section_value - def _progress_bar_item_show_func(self, _: Optional['ProgressBarValue'] = None) -> str: - return self._current_section.label - def update(self, section: 'ProgressBarSection', value: int = 1) -> None: - if not self._progress_bar: - raise ValueError('Progress bar is not initialized. Call start() first or use "with" statement.') - if section not in self._section_lengths: raise ValueError(f'{section} section is not initialized. Call set_section_length() first.') if section is not self._current_section.section: @@ -255,12 +244,9 @@ def update(self, section: 'ProgressBarSection', value: int = 1) -> None: self._increment_progress(section) self._maybe_update_current_section() - def update_label(self, label: Optional[str] = None) -> None: - if not self._progress_bar: - raise ValueError('Progress bar is not initialized. Call start() first or use "with" statement.') - - self._progress_bar.label = label or '' - self._progress_bar.render_progress() + def update_right_side_label(self, label: Optional[str] = None) -> None: + self._current_right_side_label = f'({label})' or '' + self._progress_bar_update() def get_progress_bar(*, hidden: bool, sections: ProgressBarSections) -> BaseProgressBar: @@ -284,9 +270,9 @@ def get_progress_bar(*, hidden: bool, sections: ProgressBarSections) -> BaseProg for _i in range(section_capacity): time.sleep(0.01) - bar.update_label(f'{bar_section} {_i}/{section_capacity}') + bar.update_right_side_label(f'{bar_section} {_i}/{section_capacity}') bar.update(bar_section) - bar.update_label() + bar.update_right_side_label() bar.stop() diff --git a/cycode/cyclient/__init__.py b/cycode/cyclient/__init__.py index 9bea26e9..e69de29b 100644 --- a/cycode/cyclient/__init__.py +++ b/cycode/cyclient/__init__.py @@ -1,5 +0,0 @@ -from cycode.cyclient.config import logger - -__all__ = [ - 'logger', -] diff --git a/cycode/cyclient/cycode_client_base.py b/cycode/cyclient/cycode_client_base.py index 3024de89..f2eb77bf 100644 --- a/cycode/cyclient/cycode_client_base.py +++ b/cycode/cyclient/cycode_client_base.py @@ -15,8 +15,9 @@ RequestSslError, RequestTimeout, ) -from cycode.cyclient import config, logger +from cycode.cyclient import config from cycode.cyclient.headers import get_cli_user_agent, get_correlation_id +from cycode.cyclient.logger import logger class SystemStorageSslContext(HTTPAdapter): diff --git a/cycode/cyclient/headers.py b/cycode/cyclient/headers.py index 4e2434e9..76716826 100644 --- a/cycode/cyclient/headers.py +++ b/cycode/cyclient/headers.py @@ -6,7 +6,7 @@ from cycode.cli import consts from cycode.cli.user_settings.configuration_manager import ConfigurationManager from cycode.cli.utils.sentry import add_correlation_id_to_scope -from cycode.cyclient import logger +from cycode.cyclient.logger import logger def get_cli_user_agent() -> str: From da80ead8f337a93fe22c7dc5a662907c88aa9173 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Thu, 20 Mar 2025 13:43:47 +0100 Subject: [PATCH 149/257] CM-45716 - Add rich tables with more useful information, colorful values, and clickable paths (#287) --- cycode/cli/cli_types.py | 13 ++ .../cli/printers/tables/sca_table_printer.py | 100 ++++++++------ cycode/cli/printers/tables/table.py | 53 ++++---- cycode/cli/printers/tables/table_models.py | 15 +- cycode/cli/printers/tables/table_printer.py | 128 ++++++++++-------- .../cli/printers/tables/table_printer_base.py | 18 +-- cycode/cli/utils/string_utils.py | 2 +- poetry.lock | 16 +-- pyproject.toml | 1 - tests/utils/test_string_utils.py | 2 +- 10 files changed, 189 insertions(+), 159 deletions(-) diff --git a/cycode/cli/cli_types.py b/cycode/cli/cli_types.py index 92c36fa2..83451df2 100644 --- a/cycode/cli/cli_types.py +++ b/cycode/cli/cli_types.py @@ -42,6 +42,10 @@ class SeverityOption(str, Enum): def get_member_weight(name: str) -> int: return _SEVERITY_WEIGHTS.get(name.lower(), _SEVERITY_DEFAULT_WEIGHT) + @staticmethod + def get_member_color(name: str) -> str: + return _SEVERITY_COLORS.get(name.lower(), _SEVERITY_DEFAULT_COLOR) + _SEVERITY_DEFAULT_WEIGHT = -1 _SEVERITY_WEIGHTS = { @@ -51,3 +55,12 @@ def get_member_weight(name: str) -> int: SeverityOption.HIGH.value: 3, SeverityOption.CRITICAL.value: 4, } + +_SEVERITY_DEFAULT_COLOR = 'white' +_SEVERITY_COLORS = { + SeverityOption.INFO.value: 'deep_sky_blue1', + SeverityOption.LOW.value: 'gold1', + SeverityOption.MEDIUM.value: 'dark_orange', + SeverityOption.HIGH.value: 'red1', + SeverityOption.CRITICAL.value: 'red3', +} diff --git a/cycode/cli/printers/tables/sca_table_printer.py b/cycode/cli/printers/tables/sca_table_printer.py index 063e80e8..b59a33ef 100644 --- a/cycode/cli/printers/tables/sca_table_printer.py +++ b/cycode/cli/printers/tables/sca_table_printer.py @@ -1,13 +1,13 @@ from collections import defaultdict from typing import TYPE_CHECKING, Dict, List -import click +import typer from cycode.cli.cli_types import SeverityOption from cycode.cli.consts import LICENSE_COMPLIANCE_POLICY_ID, PACKAGE_VULNERABILITY_POLICY_ID from cycode.cli.models import Detection from cycode.cli.printers.tables.table import Table -from cycode.cli.printers.tables.table_models import ColumnInfoBuilder, ColumnWidths +from cycode.cli.printers.tables.table_models import ColumnInfoBuilder from cycode.cli.printers.tables.table_printer_base import TablePrinterBase from cycode.cli.utils.string_utils import shortcut_dependency_paths @@ -19,25 +19,16 @@ # Building must have strict order. Represents the order of the columns in the table (from left to right) SEVERITY_COLUMN = column_builder.build(name='Severity') REPOSITORY_COLUMN = column_builder.build(name='Repository') -CODE_PROJECT_COLUMN = column_builder.build(name='Code Project') # File path to manifest file -ECOSYSTEM_COLUMN = column_builder.build(name='Ecosystem') -PACKAGE_COLUMN = column_builder.build(name='Package') -CVE_COLUMNS = column_builder.build(name='CVE') +CODE_PROJECT_COLUMN = column_builder.build(name='Code Project', highlight=False) # File path to the manifest file +ECOSYSTEM_COLUMN = column_builder.build(name='Ecosystem', highlight=False) +PACKAGE_COLUMN = column_builder.build(name='Package', highlight=False) +CVE_COLUMNS = column_builder.build(name='CVE', highlight=False) DEPENDENCY_PATHS_COLUMN = column_builder.build(name='Dependency Paths') UPGRADE_COLUMN = column_builder.build(name='Upgrade') -LICENSE_COLUMN = column_builder.build(name='License') +LICENSE_COLUMN = column_builder.build(name='License', highlight=False) DIRECT_DEPENDENCY_COLUMN = column_builder.build(name='Direct Dependency') DEVELOPMENT_DEPENDENCY_COLUMN = column_builder.build(name='Development Dependency') -COLUMN_WIDTHS_CONFIG: ColumnWidths = { - REPOSITORY_COLUMN: 2, - CODE_PROJECT_COLUMN: 2, - PACKAGE_COLUMN: 3, - CVE_COLUMNS: 5, - UPGRADE_COLUMN: 3, - LICENSE_COLUMN: 2, -} - class ScaTablePrinter(TablePrinterBase): def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: @@ -45,10 +36,9 @@ def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: detections_per_policy_id = self._extract_detections_per_policy_id(local_scan_results) for policy_id, detections in detections_per_policy_id.items(): table = self._get_table(policy_id) - table.set_cols_width(COLUMN_WIDTHS_CONFIG) for detection in self._sort_and_group_detections(detections): - self._enrich_table_with_values(table, detection) + self._enrich_table_with_values(policy_id, table, detection) self._print_summary_issues(len(detections), self._get_title(policy_id)) self._print_table(table) @@ -90,7 +80,7 @@ def _sort_and_group_detections(self, detections: List[Detection]) -> List[Detect """Sort detections by severity and group by repository, code project and package name. Note: - Code Project is path to manifest file. + Code Project is path to the manifest file. Grouping by code projects also groups by ecosystem. Because manifest files are unique per ecosystem. @@ -114,55 +104,77 @@ def _get_table(self, policy_id: str) -> Table: table = Table() if policy_id == PACKAGE_VULNERABILITY_POLICY_ID: - table.add(SEVERITY_COLUMN) - table.add(CVE_COLUMNS) - table.add(UPGRADE_COLUMN) + table.add_column(CVE_COLUMNS) + table.add_column(UPGRADE_COLUMN) elif policy_id == LICENSE_COMPLIANCE_POLICY_ID: - table.add(LICENSE_COLUMN) + table.add_column(LICENSE_COLUMN) if self._is_git_repository(): - table.add(REPOSITORY_COLUMN) + table.add_column(REPOSITORY_COLUMN) - table.add(CODE_PROJECT_COLUMN) - table.add(ECOSYSTEM_COLUMN) - table.add(PACKAGE_COLUMN) - table.add(DIRECT_DEPENDENCY_COLUMN) - table.add(DEVELOPMENT_DEPENDENCY_COLUMN) - table.add(DEPENDENCY_PATHS_COLUMN) + table.add_column(SEVERITY_COLUMN) + table.add_column(CODE_PROJECT_COLUMN) + table.add_column(ECOSYSTEM_COLUMN) + table.add_column(PACKAGE_COLUMN) + table.add_column(DIRECT_DEPENDENCY_COLUMN) + table.add_column(DEVELOPMENT_DEPENDENCY_COLUMN) + table.add_column(DEPENDENCY_PATHS_COLUMN) return table @staticmethod - def _enrich_table_with_values(table: Table, detection: Detection) -> None: + def _enrich_table_with_values(policy_id: str, table: Table, detection: Detection) -> None: detection_details = detection.detection_details - table.set(SEVERITY_COLUMN, detection_details.get('advisory_severity')) - table.set(REPOSITORY_COLUMN, detection_details.get('repository_name')) - - table.set(CODE_PROJECT_COLUMN, detection_details.get('file_name')) - table.set(ECOSYSTEM_COLUMN, detection_details.get('ecosystem')) - table.set(PACKAGE_COLUMN, detection_details.get('package_name')) - table.set(DIRECT_DEPENDENCY_COLUMN, detection_details.get('is_direct_dependency_str')) - table.set(DEVELOPMENT_DEPENDENCY_COLUMN, detection_details.get('is_dev_dependency_str')) + severity = None + if policy_id == PACKAGE_VULNERABILITY_POLICY_ID: + severity = detection_details.get('advisory_severity') + elif policy_id == LICENSE_COMPLIANCE_POLICY_ID: + severity = detection.severity + + if not severity: + severity = 'N/A' + + table.add_cell(SEVERITY_COLUMN, severity, SeverityOption.get_member_color(severity)) + + table.add_cell(REPOSITORY_COLUMN, detection_details.get('repository_name')) + table.add_file_path_cell(CODE_PROJECT_COLUMN, detection_details.get('file_name')) + table.add_cell(ECOSYSTEM_COLUMN, detection_details.get('ecosystem')) + table.add_cell(PACKAGE_COLUMN, detection_details.get('package_name')) + + dependency_bool_to_color = { + True: 'green', + False: 'red', + } # by default, not colored (None) + table.add_cell( + column=DIRECT_DEPENDENCY_COLUMN, + value=detection_details.get('is_direct_dependency_str'), + color=dependency_bool_to_color.get(detection_details.get('is_direct_dependency')), + ) + table.add_cell( + column=DEVELOPMENT_DEPENDENCY_COLUMN, + value=detection_details.get('is_dev_dependency_str'), + color=dependency_bool_to_color.get(detection_details.get('is_dev_dependency')), + ) dependency_paths = 'N/A' dependency_paths_raw = detection_details.get('dependency_paths') if dependency_paths_raw: dependency_paths = shortcut_dependency_paths(dependency_paths_raw) - table.set(DEPENDENCY_PATHS_COLUMN, dependency_paths) + table.add_cell(DEPENDENCY_PATHS_COLUMN, dependency_paths) upgrade = '' alert = detection_details.get('alert') if alert and alert.get('first_patched_version'): upgrade = f'{alert.get("vulnerable_requirements")} -> {alert.get("first_patched_version")}' - table.set(UPGRADE_COLUMN, upgrade) + table.add_cell(UPGRADE_COLUMN, upgrade) - table.set(CVE_COLUMNS, detection_details.get('vulnerability_id')) - table.set(LICENSE_COLUMN, detection_details.get('license')) + table.add_cell(CVE_COLUMNS, detection_details.get('vulnerability_id')) + table.add_cell(LICENSE_COLUMN, detection_details.get('license')) @staticmethod def _print_summary_issues(detections_count: int, title: str) -> None: - click.echo(f'⛔ Found {detections_count} issues of type: {click.style(title, bold=True)}') + typer.echo(f'⛔ Found {detections_count} issues of type: {typer.style(title, bold=True)}') @staticmethod def _extract_detections_per_policy_id( diff --git a/cycode/cli/printers/tables/table.py b/cycode/cli/printers/tables/table.py index 2017b9c8..a071c9b4 100644 --- a/cycode/cli/printers/tables/table.py +++ b/cycode/cli/printers/tables/table.py @@ -1,29 +1,40 @@ +import urllib.parse from typing import TYPE_CHECKING, Dict, List, Optional -from texttable import Texttable +from rich.markup import escape +from rich.table import Table as RichTable if TYPE_CHECKING: - from cycode.cli.printers.tables.table_models import ColumnInfo, ColumnWidths + from cycode.cli.printers.tables.table_models import ColumnInfo class Table: """Helper class to manage columns and their values in the right order and only if the column should be presented.""" def __init__(self, column_infos: Optional[List['ColumnInfo']] = None) -> None: - self._column_widths = None - self._columns: Dict['ColumnInfo', List[str]] = {} if column_infos: - self._columns: Dict['ColumnInfo', List[str]] = {columns: [] for columns in column_infos} + self._columns = {columns: [] for columns in column_infos} - def add(self, column: 'ColumnInfo') -> None: + def add_column(self, column: 'ColumnInfo') -> None: self._columns[column] = [] - def set(self, column: 'ColumnInfo', value: str) -> None: + def _add_cell_no_error(self, column: 'ColumnInfo', value: str) -> None: # we push values only for existing columns what were added before if column in self._columns: self._columns[column].append(value) + def add_cell(self, column: 'ColumnInfo', value: str, color: Optional[str] = None) -> None: + if color: + value = f'[{color}]{value}[/{color}]' + + self._add_cell_no_error(column, value) + + def add_file_path_cell(self, column: 'ColumnInfo', path: str) -> None: + encoded_path = urllib.parse.quote(path) + escaped_path = escape(encoded_path) + self._add_cell_no_error(column, f'[link file://{escaped_path}]{path}') + def _get_ordered_columns(self) -> List['ColumnInfo']: # we are sorting columns by index to make sure that columns will be printed in the right order return sorted(self._columns, key=lambda column_info: column_info.index) @@ -31,32 +42,18 @@ def _get_ordered_columns(self) -> List['ColumnInfo']: def get_columns_info(self) -> List['ColumnInfo']: return self._get_ordered_columns() - def get_headers(self) -> List[str]: - return [header.name for header in self._get_ordered_columns()] - def get_rows(self) -> List[str]: column_values = [self._columns[column_info] for column_info in self._get_ordered_columns()] return list(zip(*column_values)) - def set_cols_width(self, column_widths: 'ColumnWidths') -> None: - header_width_size = [] - for header in self.get_columns_info(): - width_multiplier = 1 - if header in column_widths: - width_multiplier = column_widths[header] - - header_width_size.append(len(header.name) * width_multiplier) - - self._column_widths = header_width_size - - def get_table(self, max_width: int = 80) -> Texttable: - table = Texttable(max_width) - table.header(self.get_headers()) + def get_table(self) -> 'RichTable': + table = RichTable(expand=True, highlight=True) - for row in self.get_rows(): - table.add_row(row) + for column in self.get_columns_info(): + extra_args = column.column_opts if column.column_opts else {} + table.add_column(header=column.name, overflow='fold', **extra_args) - if self._column_widths: - table.set_cols_width(self._column_widths) + for raw in self.get_rows(): + table.add_row(*raw) return table diff --git a/cycode/cli/printers/tables/table_models.py b/cycode/cli/printers/tables/table_models.py index c162a8ce..42e3b1fb 100644 --- a/cycode/cli/printers/tables/table_models.py +++ b/cycode/cli/printers/tables/table_models.py @@ -1,12 +1,12 @@ -from typing import Dict, NamedTuple +from typing import Any, Dict, NamedTuple, Optional class ColumnInfoBuilder: def __init__(self) -> None: self._index = 0 - def build(self, name: str) -> 'ColumnInfo': - column_info = ColumnInfo(name, self._index) + def build(self, name: str, **column_opts) -> 'ColumnInfo': + column_info = ColumnInfo(name, self._index, column_opts) self._index += 1 return column_info @@ -14,7 +14,12 @@ def build(self, name: str) -> 'ColumnInfo': class ColumnInfo(NamedTuple): name: str index: int # Represents the order of the columns, starting from the left + column_opts: Optional[Dict] = None + def __hash__(self) -> int: + return hash((self.name, self.index)) -ColumnWidths = Dict[ColumnInfo, int] -ColumnWidthsConfig = Dict[str, ColumnWidths] + def __eq__(self, other: Any) -> bool: + if not isinstance(other, ColumnInfo): + return NotImplemented + return (self.name, self.index) == (other.name, other.index) diff --git a/cycode/cli/printers/tables/table_printer.py b/cycode/cli/printers/tables/table_printer.py index 61234066..728e2baf 100644 --- a/cycode/cli/printers/tables/table_printer.py +++ b/cycode/cli/printers/tables/table_printer.py @@ -1,11 +1,11 @@ -from typing import TYPE_CHECKING, List +from collections import defaultdict +from typing import TYPE_CHECKING, List, Tuple -import click - -from cycode.cli.consts import IAC_SCAN_TYPE, SAST_SCAN_TYPE, SECRET_SCAN_TYPE +from cycode.cli.cli_types import SeverityOption +from cycode.cli.consts import SECRET_SCAN_TYPE from cycode.cli.models import Detection, Document from cycode.cli.printers.tables.table import Table -from cycode.cli.printers.tables.table_models import ColumnInfoBuilder, ColumnWidthsConfig +from cycode.cli.printers.tables.table_models import ColumnInfoBuilder from cycode.cli.printers.tables.table_printer_base import TablePrinterBase from cycode.cli.utils.string_utils import get_position_in_line, obfuscate_text @@ -15,73 +15,89 @@ column_builder = ColumnInfoBuilder() # Building must have strict order. Represents the order of the columns in the table (from left to right) +SEVERITY_COLUMN = column_builder.build(name='Severity') ISSUE_TYPE_COLUMN = column_builder.build(name='Issue Type') -RULE_ID_COLUMN = column_builder.build(name='Rule ID') -FILE_PATH_COLUMN = column_builder.build(name='File Path') +FILE_PATH_COLUMN = column_builder.build(name='File Path', highlight=False) SECRET_SHA_COLUMN = column_builder.build(name='Secret SHA') COMMIT_SHA_COLUMN = column_builder.build(name='Commit SHA') LINE_NUMBER_COLUMN = column_builder.build(name='Line Number') COLUMN_NUMBER_COLUMN = column_builder.build(name='Column Number') VIOLATION_LENGTH_COLUMN = column_builder.build(name='Violation Length') -VIOLATION_COLUMN = column_builder.build(name='Violation') -SCAN_ID_COLUMN = column_builder.build(name='Scan ID') - -COLUMN_WIDTHS_CONFIG: ColumnWidthsConfig = { - SECRET_SCAN_TYPE: { - ISSUE_TYPE_COLUMN: 2, - RULE_ID_COLUMN: 2, - FILE_PATH_COLUMN: 2, - SECRET_SHA_COLUMN: 2, - VIOLATION_COLUMN: 2, - SCAN_ID_COLUMN: 2, - }, - IAC_SCAN_TYPE: { - ISSUE_TYPE_COLUMN: 4, - RULE_ID_COLUMN: 3, - FILE_PATH_COLUMN: 3, - SCAN_ID_COLUMN: 2, - }, - SAST_SCAN_TYPE: { - ISSUE_TYPE_COLUMN: 7, - RULE_ID_COLUMN: 2, - FILE_PATH_COLUMN: 3, - SCAN_ID_COLUMN: 2, - }, -} +VIOLATION_COLUMN = column_builder.build(name='Violation', highlight=False) class TablePrinter(TablePrinterBase): def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: table = self._get_table() - if self.scan_type in COLUMN_WIDTHS_CONFIG: - table.set_cols_width(COLUMN_WIDTHS_CONFIG[self.scan_type]) + detections_with_documents = [] for local_scan_result in local_scan_results: for document_detections in local_scan_result.document_detections: - for detection in document_detections.detections: - table.set(SCAN_ID_COLUMN, local_scan_result.scan_id) - self._enrich_table_with_values(table, detection, document_detections.document) + detections_with_documents.extend( + [(detection, document_detections.document) for detection in document_detections.detections] + ) + + for detection, document in self._sort_and_group_detections(detections_with_documents): + self._enrich_table_with_values(table, detection, document) self._print_table(table) self._print_report_urls(local_scan_results, self.ctx.obj.get('aggregation_report_url')) + @staticmethod + def __severity_sort_key(detection_with_document: Tuple[Detection, Document]) -> int: + detection, _ = detection_with_document + severity = detection.severity if detection.severity else '' + return SeverityOption.get_member_weight(severity) + + def _sort_detections_by_severity( + self, detections_with_documents: List[Tuple[Detection, Document]] + ) -> List[Tuple[Detection, Document]]: + return sorted(detections_with_documents, key=self.__severity_sort_key, reverse=True) + + @staticmethod + def __file_path_sort_key(detection_with_document: Tuple[Detection, Document]) -> str: + _, document = detection_with_document + return document.path + + def _sort_detections_by_file_path( + self, detections_with_documents: List[Tuple[Detection, Document]] + ) -> List[Tuple[Detection, Document]]: + return sorted(detections_with_documents, key=self.__file_path_sort_key) + + def _sort_and_group_detections( + self, detections_with_documents: List[Tuple[Detection, Document]] + ) -> List[Tuple[Detection, Document]]: + """Sort detections by severity and group by file name.""" + result = [] + + # we sort detections by file path to make persist output order + sorted_detections = self._sort_detections_by_file_path(detections_with_documents) + + grouped_by_file_path = defaultdict(list) + for detection, document in sorted_detections: + grouped_by_file_path[document.path].append((detection, document)) + + for file_path_group in grouped_by_file_path.values(): + result.extend(self._sort_detections_by_severity(file_path_group)) + + return result + def _get_table(self) -> Table: table = Table() - table.add(ISSUE_TYPE_COLUMN) - table.add(RULE_ID_COLUMN) - table.add(FILE_PATH_COLUMN) - table.add(LINE_NUMBER_COLUMN) - table.add(COLUMN_NUMBER_COLUMN) - table.add(SCAN_ID_COLUMN) + table.add_column(SEVERITY_COLUMN) + table.add_column(ISSUE_TYPE_COLUMN) + table.add_column(FILE_PATH_COLUMN) + table.add_column(LINE_NUMBER_COLUMN) + table.add_column(COLUMN_NUMBER_COLUMN) if self._is_git_repository(): - table.add(COMMIT_SHA_COLUMN) + table.add_column(COMMIT_SHA_COLUMN) if self.scan_type == SECRET_SCAN_TYPE: - table.add(SECRET_SHA_COLUMN) - table.add(VIOLATION_LENGTH_COLUMN) - table.add(VIOLATION_COLUMN) + table.add_column(SECRET_SHA_COLUMN) + table.add_column(VIOLATION_LENGTH_COLUMN) + table.add_column(VIOLATION_COLUMN) return table @@ -96,11 +112,11 @@ def _enrich_table_with_detection_summary_values( if self.scan_type == SECRET_SCAN_TYPE: issue_type = detection.type - table.set(ISSUE_TYPE_COLUMN, issue_type) - table.set(RULE_ID_COLUMN, detection.detection_rule_id) - table.set(FILE_PATH_COLUMN, click.format_filename(document.path)) - table.set(SECRET_SHA_COLUMN, detection.detection_details.get('sha512', '')) - table.set(COMMIT_SHA_COLUMN, detection.detection_details.get('commit_id', '')) + table.add_cell(SEVERITY_COLUMN, detection.severity, SeverityOption.get_member_color(detection.severity)) + table.add_cell(ISSUE_TYPE_COLUMN, issue_type) + table.add_file_path_cell(FILE_PATH_COLUMN, document.path) + table.add_cell(SECRET_SHA_COLUMN, detection.detection_details.get('sha512', '')) + table.add_cell(COMMIT_SHA_COLUMN, detection.detection_details.get('commit_id', '')) def _enrich_table_with_detection_code_segment_values( self, table: Table, detection: Detection, document: Document @@ -123,7 +139,7 @@ def _enrich_table_with_detection_code_segment_values( if not self.show_secret: violation = obfuscate_text(violation) - table.set(LINE_NUMBER_COLUMN, str(detection_line)) - table.set(COLUMN_NUMBER_COLUMN, str(detection_column)) - table.set(VIOLATION_LENGTH_COLUMN, f'{violation_length} chars') - table.set(VIOLATION_COLUMN, violation) + table.add_cell(LINE_NUMBER_COLUMN, str(detection_line)) + table.add_cell(COLUMN_NUMBER_COLUMN, str(detection_column)) + table.add_cell(VIOLATION_LENGTH_COLUMN, f'{violation_length} chars') + table.add_cell(VIOLATION_COLUMN, violation) diff --git a/cycode/cli/printers/tables/table_printer_base.py b/cycode/cli/printers/tables/table_printer_base.py index abbc8251..71b4f399 100644 --- a/cycode/cli/printers/tables/table_printer_base.py +++ b/cycode/cli/printers/tables/table_printer_base.py @@ -1,8 +1,8 @@ import abc from typing import TYPE_CHECKING, Dict, List, Optional -import click import typer +from rich.console import Console from cycode.cli.models import CliError, CliResult from cycode.cli.printers.printer_base import PrinterBase @@ -29,7 +29,7 @@ def print_scan_results( self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None ) -> None: if not errors and all(result.issue_detected == 0 for result in local_scan_results): - click.secho('Good job! No issues were found!!! 👏👏👏', fg=self.GREEN_COLOR_NAME) + typer.secho('Good job! No issues were found!!! 👏👏👏', fg=self.GREEN_COLOR_NAME) return self._print_results(local_scan_results) @@ -37,17 +37,17 @@ def print_scan_results( if not errors: return - click.secho( + typer.secho( 'Unfortunately, Cycode was unable to complete the full scan. ' 'Please note that not all results may be available:', fg='red', ) for scan_id, error in errors.items(): - click.echo(f'- {scan_id}: ', nl=False) + typer.echo(f'- {scan_id}: ', nl=False) self.print_error(error) def _is_git_repository(self) -> bool: - return self.ctx.obj.get('remote_url') is not None + return self.ctx.info_name in {'commit_history', 'pre_commit', 'pre_receive'} and 'remote_url' in self.ctx.obj @abc.abstractmethod def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: @@ -56,7 +56,7 @@ def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: @staticmethod def _print_table(table: 'Table') -> None: if table.get_rows(): - click.echo(table.get_table().draw()) + Console().print(table.get_table()) @staticmethod def _print_report_urls( @@ -67,9 +67,9 @@ def _print_report_urls( if not report_urls and not aggregation_report_url: return if aggregation_report_url: - click.echo(f'Report URL: {aggregation_report_url}') + typer.echo(f'Report URL: {aggregation_report_url}') return - click.echo('Report URLs:') + typer.echo('Report URLs:') for report_url in report_urls: - click.echo(f'- {report_url}') + typer.echo(f'- {report_url}') diff --git a/cycode/cli/utils/string_utils.py b/cycode/cli/utils/string_utils.py index 9dce0026..c3c0c6c6 100644 --- a/cycode/cli/utils/string_utils.py +++ b/cycode/cli/utils/string_utils.py @@ -62,6 +62,6 @@ def shortcut_dependency_paths(dependency_paths_list: str) -> str: result += dependency_paths else: result += f'{dependencies[0]} -> ... -> {dependencies[-1]}' - result += '\n\n' + result += '\n' return result.rstrip().rstrip(',') diff --git a/poetry.lock b/poetry.lock index f104cc28..186fee3f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.0 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. [[package]] name = "altgraph" @@ -980,18 +980,6 @@ files = [ {file = "smmap-5.0.1.tar.gz", hash = "sha256:dceeb6c0028fdb6734471eb07c0cd2aae706ccaecab45965ee83f11c8d3b1f62"}, ] -[[package]] -name = "texttable" -version = "1.7.0" -description = "module to create simple ASCII tables" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "texttable-1.7.0-py2.py3-none-any.whl", hash = "sha256:72227d592c82b3d7f672731ae73e4d1f88cd8e2ef5b075a7a7f01a23a3743917"}, - {file = "texttable-1.7.0.tar.gz", hash = "sha256:2d2068fb55115807d3ac77a4ca68fa48803e84ebb0ee2340f858107a36522638"}, -] - [[package]] name = "tomli" version = "2.2.1" @@ -1130,4 +1118,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9,<3.14" -content-hash = "c0140dc408f1e3827b51357d74b05274297c233de11dbca85d4b6f3a909f4191" +content-hash = "2c45abb3ea36096f8dbe862ba8bf5e064994ed9cdf86a8256643b9822c40168f" diff --git a/pyproject.toml b/pyproject.toml index 6baffad9..ef555019 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,6 @@ marshmallow = ">=3.15.0,<3.23.0" # 3.23 dropped support for Python 3.8 gitpython = ">=3.1.30,<3.2.0" arrow = ">=1.0.0,<1.4.0" binaryornot = ">=0.4.4,<0.5.0" -texttable = ">=1.6.7,<1.8.0" requests = ">=2.32.2,<3.0" urllib3 = "1.26.19" # lock v1 to avoid issues with openssl and old Python versions (<3.9.11) on macOS sentry-sdk = ">=2.8.0,<3.0" diff --git a/tests/utils/test_string_utils.py b/tests/utils/test_string_utils.py index 60d10efa..8c94fceb 100644 --- a/tests/utils/test_string_utils.py +++ b/tests/utils/test_string_utils.py @@ -3,5 +3,5 @@ def test_shortcut_dependency_paths_list_single_dependencies() -> None: dependency_paths = 'A, A -> B, A -> B -> C' - expected_result = 'A\n\nA -> B\n\nA -> ... -> C' + expected_result = 'A\nA -> B\nA -> ... -> C' assert shortcut_dependency_paths(dependency_paths) == expected_result From 27ca8631a45dd94f4aad41b1919cbc390628119e Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Fri, 21 Mar 2025 12:54:12 +0100 Subject: [PATCH 150/257] CM-46055 - Fix scan parameters (#288) --- cycode/cli/commands/scan/code_scanner.py | 126 ++++++++++-------- .../scan/pre_commit/pre_commit_command.py | 4 +- .../scan/repository/repository_command.py | 3 +- tests/test_code_scanner.py | 4 +- 4 files changed, 72 insertions(+), 65 deletions(-) diff --git a/cycode/cli/commands/scan/code_scanner.py b/cycode/cli/commands/scan/code_scanner.py index 5f10ffdf..0dbf63ea 100644 --- a/cycode/cli/commands/scan/code_scanner.py +++ b/cycode/cli/commands/scan/code_scanner.py @@ -45,7 +45,7 @@ def scan_sca_pre_commit(context: click.Context) -> None: scan_type = context.obj['scan_type'] - scan_parameters = get_default_scan_parameters(context) + scan_parameters = get_scan_parameters(context) git_head_documents, pre_committed_documents = get_pre_commit_modified_documents( context.obj['progress_bar'], ScanProgressBarSection.PREPARE_LOCAL_FILES ) @@ -80,14 +80,13 @@ def scan_sca_commit_range(context: click.Context, path: str, commit_range: str) def scan_disk_files(context: click.Context, paths: Tuple[str]) -> None: - scan_parameters = get_scan_parameters(context, paths) scan_type = context.obj['scan_type'] progress_bar = context.obj['progress_bar'] try: documents = get_relevant_documents(progress_bar, ScanProgressBarSection.PREPARE_LOCAL_FILES, scan_type, paths) perform_pre_scan_documents_actions(context, scan_type, documents) - scan_documents(context, documents, scan_parameters=scan_parameters) + scan_documents(context, documents, get_scan_parameters(context, paths)) except Exception as e: handle_scan_exception(context, e) @@ -151,14 +150,12 @@ def _enrich_scan_result_with_data_from_detection_rules( def _get_scan_documents_thread_func( context: click.Context, is_git_diff: bool, is_commit_range: bool, scan_parameters: dict -) -> Tuple[Callable[[List[Document]], Tuple[str, CliError, LocalScanResult]], str]: +) -> Callable[[List[Document]], Tuple[str, CliError, LocalScanResult]]: cycode_client = context.obj['client'] scan_type = context.obj['scan_type'] severity_threshold = context.obj['severity_threshold'] sync_option = context.obj['sync'] command_scan_type = context.info_name - aggregation_id = str(_generate_unique_id()) - scan_parameters['aggregation_id'] = aggregation_id def _scan_batch_thread_func(batch: List[Document]) -> Tuple[str, CliError, LocalScanResult]: local_scan_result = error = error_message = None @@ -227,7 +224,7 @@ def _scan_batch_thread_func(batch: List[Document]) -> Tuple[str, CliError, Local return scan_id, error, local_scan_result - return _scan_batch_thread_func, aggregation_id + return _scan_batch_thread_func def scan_commit_range( @@ -287,20 +284,19 @@ def scan_commit_range( logger.debug('List of commit ids to scan, %s', {'commit_ids': commit_ids_to_scan}) logger.debug('Starting to scan commit range (it may take a few minutes)') - scan_documents(context, documents_to_scan, is_git_diff=True, is_commit_range=True) + scan_documents( + context, documents_to_scan, get_scan_parameters(context, (path,)), is_git_diff=True, is_commit_range=True + ) return None def scan_documents( context: click.Context, documents_to_scan: List[Document], + scan_parameters: dict, is_git_diff: bool = False, is_commit_range: bool = False, - scan_parameters: Optional[dict] = None, ) -> None: - if not scan_parameters: - scan_parameters = get_default_scan_parameters(context) - scan_type = context.obj['scan_type'] progress_bar = context.obj['progress_bar'] @@ -315,19 +311,15 @@ def scan_documents( ) return - scan_batch_thread_func, aggregation_id = _get_scan_documents_thread_func( - context, is_git_diff, is_commit_range, scan_parameters - ) + scan_batch_thread_func = _get_scan_documents_thread_func(context, is_git_diff, is_commit_range, scan_parameters) errors, local_scan_results = run_parallel_batched_scan( scan_batch_thread_func, scan_type, documents_to_scan, progress_bar=progress_bar ) - if len(local_scan_results) > 1: - # if we used more than one batch, we need to fetch aggregate report url - aggregation_report_url = _try_get_aggregation_report_url_if_needed( - scan_parameters, context.obj['client'], scan_type - ) - set_aggregation_report_url(context, aggregation_report_url) + aggregation_report_url = _try_get_aggregation_report_url_if_needed( + scan_parameters, context.obj['client'], scan_type + ) + _set_aggregation_report_url(context, aggregation_report_url) progress_bar.set_section_length(ScanProgressBarSection.GENERATE_REPORT, 1) progress_bar.update(ScanProgressBarSection.GENERATE_REPORT) @@ -337,25 +329,6 @@ def scan_documents( print_results(context, local_scan_results, errors) -def set_aggregation_report_url(context: click.Context, aggregation_report_url: Optional[str] = None) -> None: - context.obj['aggregation_report_url'] = aggregation_report_url - - -def _try_get_aggregation_report_url_if_needed( - scan_parameters: dict, cycode_client: 'ScanClient', scan_type: str -) -> Optional[str]: - aggregation_id = scan_parameters.get('aggregation_id') - if not scan_parameters.get('report'): - return None - if aggregation_id is None: - return None - try: - report_url_response = cycode_client.get_scan_aggregation_report_url(aggregation_id, scan_type) - return report_url_response.report_url - except Exception as e: - logger.debug('Failed to get aggregation report url: %s', str(e)) - - def scan_commit_range_documents( context: click.Context, from_documents_to_scan: List[Document], @@ -380,7 +353,7 @@ def scan_commit_range_documents( try: progress_bar.set_section_length(ScanProgressBarSection.SCAN, 1) - scan_result = init_default_scan_result(cycode_client, scan_id, scan_type) + scan_result = init_default_scan_result(scan_id) if should_scan_documents(from_documents_to_scan, to_documents_to_scan): logger.debug('Preparing from-commit zip') from_commit_zipped_documents = zip_documents(scan_type, from_documents_to_scan) @@ -518,7 +491,7 @@ def perform_scan_async( cycode_client, scan_async_result.scan_id, scan_type, - scan_parameters.get('report'), + scan_parameters, ) @@ -553,16 +526,14 @@ def perform_commit_range_scan_async( logger.debug( 'Async commit range scan request has been triggered successfully, %s', {'scan_id': scan_async_result.scan_id} ) - return poll_scan_results( - cycode_client, scan_async_result.scan_id, scan_type, scan_parameters.get('report'), timeout - ) + return poll_scan_results(cycode_client, scan_async_result.scan_id, scan_type, scan_parameters, timeout) def poll_scan_results( cycode_client: 'ScanClient', scan_id: str, scan_type: str, - should_get_report: bool = False, + scan_parameters: dict, polling_timeout: Optional[int] = None, ) -> ZippedFileScanResult: if polling_timeout is None: @@ -579,7 +550,7 @@ def poll_scan_results( print_debug_scan_details(scan_details) if scan_details.scan_status == consts.SCAN_STATUS_COMPLETED: - return _get_scan_result(cycode_client, scan_type, scan_id, scan_details, should_get_report) + return _get_scan_result(cycode_client, scan_type, scan_id, scan_details, scan_parameters) if scan_details.scan_status == consts.SCAN_STATUS_ERROR: raise custom_exceptions.ScanAsyncError( @@ -671,18 +642,19 @@ def parse_pre_receive_input() -> str: return pre_receive_input.splitlines()[0] -def get_default_scan_parameters(context: click.Context) -> dict: +def _get_default_scan_parameters(context: click.Context) -> dict: return { 'monitor': context.obj.get('monitor'), 'report': context.obj.get('report'), 'package_vulnerabilities': context.obj.get('package-vulnerabilities'), 'license_compliance': context.obj.get('license-compliance'), 'command_type': context.info_name, + 'aggregation_id': str(_generate_unique_id()), } -def get_scan_parameters(context: click.Context, paths: Tuple[str]) -> dict: - scan_parameters = get_default_scan_parameters(context) +def get_scan_parameters(context: click.Context, paths: Optional[Tuple[str]] = None) -> dict: + scan_parameters = _get_default_scan_parameters(context) if not paths: return scan_parameters @@ -890,10 +862,10 @@ def _get_scan_result( scan_type: str, scan_id: str, scan_details: 'ScanDetailsResponse', - should_get_report: bool = False, + scan_parameters: dict, ) -> ZippedFileScanResult: if not scan_details.detections_count: - return init_default_scan_result(cycode_client, scan_id, scan_type, should_get_report) + return init_default_scan_result(scan_id) scan_raw_detections = cycode_client.get_scan_raw_detections(scan_type, scan_id) @@ -901,25 +873,40 @@ def _get_scan_result( did_detect=True, detections_per_file=_map_detections_per_file_and_commit_id(scan_type, scan_raw_detections), scan_id=scan_id, - report_url=_try_get_report_url_if_needed(cycode_client, should_get_report, scan_id, scan_type), + report_url=_try_get_any_report_url_if_needed(cycode_client, scan_id, scan_type, scan_parameters), ) -def init_default_scan_result( - cycode_client: 'ScanClient', scan_id: str, scan_type: str, should_get_report: bool = False -) -> ZippedFileScanResult: +def init_default_scan_result(scan_id: str) -> ZippedFileScanResult: return ZippedFileScanResult( did_detect=False, detections_per_file=[], scan_id=scan_id, - report_url=_try_get_report_url_if_needed(cycode_client, should_get_report, scan_id, scan_type), ) +def _try_get_any_report_url_if_needed( + cycode_client: 'ScanClient', + scan_id: str, + scan_type: str, + scan_parameters: dict, +) -> Optional[str]: + """Tries to get aggregation report URL if needed, otherwise tries to get report URL.""" + aggregation_report_url = None + if scan_parameters: + _try_get_report_url_if_needed(cycode_client, scan_id, scan_type, scan_parameters) + aggregation_report_url = _try_get_aggregation_report_url_if_needed(scan_parameters, cycode_client, scan_type) + + if aggregation_report_url: + return aggregation_report_url + + return _try_get_report_url_if_needed(cycode_client, scan_id, scan_type, scan_parameters) + + def _try_get_report_url_if_needed( - cycode_client: 'ScanClient', should_get_report: bool, scan_id: str, scan_type: str + cycode_client: 'ScanClient', scan_id: str, scan_type: str, scan_parameters: dict ) -> Optional[str]: - if not should_get_report: + if not scan_parameters.get('report', False): return None try: @@ -929,6 +916,27 @@ def _try_get_report_url_if_needed( logger.debug('Failed to get report URL', exc_info=e) +def _set_aggregation_report_url(context: click.Context, aggregation_report_url: Optional[str] = None) -> None: + context.obj['aggregation_report_url'] = aggregation_report_url + + +def _try_get_aggregation_report_url_if_needed( + scan_parameters: dict, cycode_client: 'ScanClient', scan_type: str +) -> Optional[str]: + if not scan_parameters.get('report', False): + return None + + aggregation_id = scan_parameters.get('aggregation_id') + if aggregation_id is None: + return None + + try: + report_url_response = cycode_client.get_scan_aggregation_report_url(aggregation_id, scan_type) + return report_url_response.report_url + except Exception as e: + logger.debug('Failed to get aggregation report url: %s', str(e)) + + def _map_detections_per_file_and_commit_id(scan_type: str, raw_detections: List[dict]) -> List[DetectionsPerFile]: """Converts list of detections (async flow) to list of DetectionsPerFile objects (sync flow). 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 fa4b295a..e71f2772 100644 --- a/cycode/cli/commands/scan/pre_commit/pre_commit_command.py +++ b/cycode/cli/commands/scan/pre_commit/pre_commit_command.py @@ -4,7 +4,7 @@ import click from cycode.cli import consts -from cycode.cli.commands.scan.code_scanner import scan_documents, scan_sca_pre_commit +from cycode.cli.commands.scan.code_scanner import get_scan_parameters, scan_documents, scan_sca_pre_commit from cycode.cli.files_collector.excluder import exclude_irrelevant_documents_to_scan from cycode.cli.files_collector.repository_documents import ( get_diff_file_content, @@ -44,4 +44,4 @@ def pre_commit_command(context: click.Context, ignored_args: List[str]) -> None: documents_to_scan.append(Document(get_path_by_os(get_diff_file_path(file)), get_diff_file_content(file))) documents_to_scan = exclude_irrelevant_documents_to_scan(scan_type, documents_to_scan) - scan_documents(context, documents_to_scan, is_git_diff=True) + scan_documents(context, documents_to_scan, get_scan_parameters(context), is_git_diff=True) diff --git a/cycode/cli/commands/scan/repository/repository_command.py b/cycode/cli/commands/scan/repository/repository_command.py index 9485c31c..b0a0effb 100644 --- a/cycode/cli/commands/scan/repository/repository_command.py +++ b/cycode/cli/commands/scan/repository/repository_command.py @@ -63,7 +63,6 @@ def repository_command(context: click.Context, path: str, branch: str) -> None: perform_pre_scan_documents_actions(context, scan_type, documents_to_scan) logger.debug('Found all relevant files for scanning %s', {'path': path, 'branch': branch}) - scan_parameters = get_scan_parameters(context, (path,)) - scan_documents(context, documents_to_scan, scan_parameters=scan_parameters) + scan_documents(context, documents_to_scan, get_scan_parameters(context, (path,))) except Exception as e: handle_scan_exception(context, e) diff --git a/tests/test_code_scanner.py b/tests/test_code_scanner.py index 10726a65..d0ae939a 100644 --- a/tests/test_code_scanner.py +++ b/tests/test_code_scanner.py @@ -29,7 +29,7 @@ def test_is_relevant_file_to_scan_sca() -> None: @pytest.mark.parametrize('scan_type', config['scans']['supported_scans']) def test_try_get_report_url_if_needed_return_none(scan_type: str, scan_client: ScanClient) -> None: scan_id = uuid4().hex - result = _try_get_report_url_if_needed(scan_client, False, scan_id, consts.SECRET_SCAN_TYPE) + result = _try_get_report_url_if_needed(scan_client, scan_id, consts.SECRET_SCAN_TYPE, scan_parameters={}) assert result is None @@ -44,7 +44,7 @@ def test_try_get_report_url_if_needed_return_result( responses.add(get_scan_report_url_response(url, scan_id)) scan_report_url_response = scan_client.get_scan_report_url(str(scan_id), scan_type) - result = _try_get_report_url_if_needed(scan_client, True, str(scan_id), scan_type) + result = _try_get_report_url_if_needed(scan_client, str(scan_id), scan_type, scan_parameters={'report': True}) assert result == scan_report_url_response.report_url From 72e8b777ab8d4a34a71960c35af808a243b0978c Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Mon, 24 Mar 2025 13:40:59 +0100 Subject: [PATCH 151/257] CM-46137 - Add visual separators of row groups; reorder columns (#289) --- cycode/cli/apps/scan/code_scanner.py | 124 +++++++++--------- .../scan/pre_commit/pre_commit_command.py | 4 +- .../scan/repository/repository_command.py | 3 +- .../cli/printers/tables/sca_table_printer.py | 17 ++- cycode/cli/printers/tables/table.py | 11 +- cycode/cli/printers/tables/table_printer.py | 25 ++-- tests/test_code_scanner.py | 4 +- 7 files changed, 103 insertions(+), 85 deletions(-) diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index a1a5d440..a3b1201e 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -49,7 +49,7 @@ def scan_sca_pre_commit(ctx: typer.Context) -> None: scan_type = ctx.obj['scan_type'] - scan_parameters = get_default_scan_parameters(ctx) + scan_parameters = get_scan_parameters(ctx) git_head_documents, pre_committed_documents = get_pre_commit_modified_documents( ctx.obj['progress_bar'], ScanProgressBarSection.PREPARE_LOCAL_FILES ) @@ -83,15 +83,14 @@ def scan_sca_commit_range(ctx: typer.Context, path: str, commit_range: str) -> N scan_commit_range_documents(ctx, from_commit_documents, to_commit_documents, scan_parameters=scan_parameters) -def scan_disk_files(ctx: typer.Context, paths: Tuple[str, ...]) -> None: - scan_parameters = get_scan_parameters(ctx, paths) +def scan_disk_files(ctx: click.Context, paths: Tuple[str]) -> None: scan_type = ctx.obj['scan_type'] progress_bar = ctx.obj['progress_bar'] try: documents = get_relevant_documents(progress_bar, ScanProgressBarSection.PREPARE_LOCAL_FILES, scan_type, paths) perform_pre_scan_documents_actions(ctx, scan_type, documents) - scan_documents(ctx, documents, scan_parameters=scan_parameters) + scan_documents(ctx, documents, get_scan_parameters(ctx, paths)) except Exception as e: handle_scan_exception(ctx, e) @@ -155,14 +154,12 @@ def _enrich_scan_result_with_data_from_detection_rules( def _get_scan_documents_thread_func( ctx: typer.Context, is_git_diff: bool, is_commit_range: bool, scan_parameters: dict -) -> Tuple[Callable[[List[Document]], Tuple[str, CliError, LocalScanResult]], str]: +) -> Callable[[List[Document]], Tuple[str, CliError, LocalScanResult]]: cycode_client = ctx.obj['client'] scan_type = ctx.obj['scan_type'] severity_threshold = ctx.obj['severity_threshold'] sync_option = ctx.obj['sync'] command_scan_type = ctx.info_name - aggregation_id = str(_generate_unique_id()) - scan_parameters['aggregation_id'] = aggregation_id def _scan_batch_thread_func(batch: List[Document]) -> Tuple[str, CliError, LocalScanResult]: local_scan_result = error = error_message = None @@ -231,7 +228,7 @@ def _scan_batch_thread_func(batch: List[Document]) -> Tuple[str, CliError, Local return scan_id, error, local_scan_result - return _scan_batch_thread_func, aggregation_id + return _scan_batch_thread_func def scan_commit_range( @@ -291,20 +288,17 @@ def scan_commit_range( logger.debug('List of commit ids to scan, %s', {'commit_ids': commit_ids_to_scan}) logger.debug('Starting to scan commit range (it may take a few minutes)') - scan_documents(ctx, documents_to_scan, is_git_diff=True, is_commit_range=True) + scan_documents(ctx, documents_to_scan, get_scan_parameters(ctx, (path,)), is_git_diff=True, is_commit_range=True) return None def scan_documents( ctx: typer.Context, documents_to_scan: List[Document], + scan_parameters: dict, is_git_diff: bool = False, is_commit_range: bool = False, - scan_parameters: Optional[dict] = None, ) -> None: - if not scan_parameters: - scan_parameters = get_default_scan_parameters(ctx) - scan_type = ctx.obj['scan_type'] progress_bar = ctx.obj['progress_bar'] @@ -319,19 +313,13 @@ def scan_documents( ) return - scan_batch_thread_func, aggregation_id = _get_scan_documents_thread_func( - ctx, is_git_diff, is_commit_range, scan_parameters - ) + scan_batch_thread_func = _get_scan_documents_thread_func(ctx, is_git_diff, is_commit_range, scan_parameters) errors, local_scan_results = run_parallel_batched_scan( scan_batch_thread_func, scan_type, documents_to_scan, progress_bar=progress_bar ) - if len(local_scan_results) > 1: - # if we used more than one batch, we need to fetch aggregate report url - aggregation_report_url = _try_get_aggregation_report_url_if_needed( - scan_parameters, ctx.obj['client'], scan_type - ) - set_aggregation_report_url(ctx, aggregation_report_url) + aggregation_report_url = _try_get_aggregation_report_url_if_needed(scan_parameters, ctx.obj['client'], scan_type) + _set_aggregation_report_url(ctx, aggregation_report_url) progress_bar.set_section_length(ScanProgressBarSection.GENERATE_REPORT, 1) progress_bar.update(ScanProgressBarSection.GENERATE_REPORT) @@ -341,25 +329,6 @@ def scan_documents( print_results(ctx, local_scan_results, errors) -def set_aggregation_report_url(ctx: typer.Context, aggregation_report_url: Optional[str] = None) -> None: - ctx.obj['aggregation_report_url'] = aggregation_report_url - - -def _try_get_aggregation_report_url_if_needed( - scan_parameters: dict, cycode_client: 'ScanClient', scan_type: str -) -> Optional[str]: - aggregation_id = scan_parameters.get('aggregation_id') - if not scan_parameters.get('report'): - return None - if aggregation_id is None: - return None - try: - report_url_response = cycode_client.get_scan_aggregation_report_url(aggregation_id, scan_type) - return report_url_response.report_url - except Exception as e: - logger.debug('Failed to get aggregation report url: %s', str(e)) - - def scan_commit_range_documents( ctx: typer.Context, from_documents_to_scan: List[Document], @@ -384,7 +353,7 @@ def scan_commit_range_documents( try: progress_bar.set_section_length(ScanProgressBarSection.SCAN, 1) - scan_result = init_default_scan_result(cycode_client, scan_id, scan_type) + scan_result = init_default_scan_result(scan_id) if should_scan_documents(from_documents_to_scan, to_documents_to_scan): logger.debug('Preparing from-commit zip') from_commit_zipped_documents = zip_documents(scan_type, from_documents_to_scan) @@ -522,7 +491,7 @@ def perform_scan_async( cycode_client, scan_async_result.scan_id, scan_type, - scan_parameters.get('report'), + scan_parameters, ) @@ -557,16 +526,14 @@ def perform_commit_range_scan_async( logger.debug( 'Async commit range scan request has been triggered successfully, %s', {'scan_id': scan_async_result.scan_id} ) - return poll_scan_results( - cycode_client, scan_async_result.scan_id, scan_type, scan_parameters.get('report'), timeout - ) + return poll_scan_results(cycode_client, scan_async_result.scan_id, scan_type, scan_parameters, timeout) def poll_scan_results( cycode_client: 'ScanClient', scan_id: str, scan_type: str, - should_get_report: bool = False, + scan_parameters: dict, polling_timeout: Optional[int] = None, ) -> ZippedFileScanResult: if polling_timeout is None: @@ -583,7 +550,7 @@ def poll_scan_results( print_debug_scan_details(scan_details) if scan_details.scan_status == consts.SCAN_STATUS_COMPLETED: - return _get_scan_result(cycode_client, scan_type, scan_id, scan_details, should_get_report) + return _get_scan_result(cycode_client, scan_type, scan_id, scan_details, scan_parameters) if scan_details.scan_status == consts.SCAN_STATUS_ERROR: raise custom_exceptions.ScanAsyncError( @@ -675,18 +642,19 @@ def parse_pre_receive_input() -> str: return pre_receive_input.splitlines()[0] -def get_default_scan_parameters(ctx: typer.Context) -> dict: +def _get_default_scan_parameters(ctx: click.Context) -> dict: return { 'monitor': ctx.obj.get('monitor'), 'report': ctx.obj.get('report'), 'package_vulnerabilities': ctx.obj.get('package-vulnerabilities'), 'license_compliance': ctx.obj.get('license-compliance'), 'command_type': ctx.info_name, + 'aggregation_id': str(_generate_unique_id()), } -def get_scan_parameters(ctx: typer.Context, paths: Tuple[str, ...]) -> dict: - scan_parameters = get_default_scan_parameters(ctx) +def get_scan_parameters(ctx: typer.Context, paths: Optional[Tuple[str]] = None) -> dict: + scan_parameters = _get_default_scan_parameters(ctx) if not paths: return scan_parameters @@ -894,10 +862,10 @@ def _get_scan_result( scan_type: str, scan_id: str, scan_details: 'ScanDetailsResponse', - should_get_report: bool = False, + scan_parameters: dict, ) -> ZippedFileScanResult: if not scan_details.detections_count: - return init_default_scan_result(cycode_client, scan_id, scan_type, should_get_report) + return init_default_scan_result(scan_id) scan_raw_detections = cycode_client.get_scan_raw_detections(scan_type, scan_id) @@ -905,25 +873,40 @@ def _get_scan_result( did_detect=True, detections_per_file=_map_detections_per_file_and_commit_id(scan_type, scan_raw_detections), scan_id=scan_id, - report_url=_try_get_report_url_if_needed(cycode_client, should_get_report, scan_id, scan_type), + report_url=_try_get_any_report_url_if_needed(cycode_client, scan_id, scan_type, scan_parameters), ) -def init_default_scan_result( - cycode_client: 'ScanClient', scan_id: str, scan_type: str, should_get_report: bool = False -) -> ZippedFileScanResult: +def init_default_scan_result(scan_id: str) -> ZippedFileScanResult: return ZippedFileScanResult( did_detect=False, detections_per_file=[], scan_id=scan_id, - report_url=_try_get_report_url_if_needed(cycode_client, should_get_report, scan_id, scan_type), ) +def _try_get_any_report_url_if_needed( + cycode_client: 'ScanClient', + scan_id: str, + scan_type: str, + scan_parameters: dict, +) -> Optional[str]: + """Tries to get aggregation report URL if needed, otherwise tries to get report URL.""" + aggregation_report_url = None + if scan_parameters: + _try_get_report_url_if_needed(cycode_client, scan_id, scan_type, scan_parameters) + aggregation_report_url = _try_get_aggregation_report_url_if_needed(scan_parameters, cycode_client, scan_type) + + if aggregation_report_url: + return aggregation_report_url + + return _try_get_report_url_if_needed(cycode_client, scan_id, scan_type, scan_parameters) + + def _try_get_report_url_if_needed( - cycode_client: 'ScanClient', should_get_report: bool, scan_id: str, scan_type: str + cycode_client: 'ScanClient', scan_id: str, scan_type: str, scan_parameters: dict ) -> Optional[str]: - if not should_get_report: + if not scan_parameters.get('report', False): return None try: @@ -933,6 +916,27 @@ def _try_get_report_url_if_needed( logger.debug('Failed to get report URL', exc_info=e) +def _set_aggregation_report_url(ctx: click.Context, aggregation_report_url: Optional[str] = None) -> None: + ctx.obj['aggregation_report_url'] = aggregation_report_url + + +def _try_get_aggregation_report_url_if_needed( + scan_parameters: dict, cycode_client: 'ScanClient', scan_type: str +) -> Optional[str]: + if not scan_parameters.get('report', False): + return None + + aggregation_id = scan_parameters.get('aggregation_id') + if aggregation_id is None: + return None + + try: + report_url_response = cycode_client.get_scan_aggregation_report_url(aggregation_id, scan_type) + return report_url_response.report_url + except Exception as e: + logger.debug('Failed to get aggregation report url: %s', str(e)) + + def _map_detections_per_file_and_commit_id(scan_type: str, raw_detections: List[dict]) -> List[DetectionsPerFile]: """Converts list of detections (async flow) to list of DetectionsPerFile objects (sync flow). diff --git a/cycode/cli/apps/scan/pre_commit/pre_commit_command.py b/cycode/cli/apps/scan/pre_commit/pre_commit_command.py index d88db8cc..8e528d15 100644 --- a/cycode/cli/apps/scan/pre_commit/pre_commit_command.py +++ b/cycode/cli/apps/scan/pre_commit/pre_commit_command.py @@ -4,7 +4,7 @@ import typer from cycode.cli import consts -from cycode.cli.apps.scan.code_scanner import scan_documents, scan_sca_pre_commit +from cycode.cli.apps.scan.code_scanner import get_scan_parameters, scan_documents, scan_sca_pre_commit from cycode.cli.files_collector.excluder import exclude_irrelevant_documents_to_scan from cycode.cli.files_collector.repository_documents import ( get_diff_file_content, @@ -44,4 +44,4 @@ def pre_commit_command( documents_to_scan.append(Document(get_path_by_os(get_diff_file_path(file)), get_diff_file_content(file))) documents_to_scan = exclude_irrelevant_documents_to_scan(scan_type, documents_to_scan) - scan_documents(ctx, documents_to_scan, is_git_diff=True) + scan_documents(ctx, documents_to_scan, get_scan_parameters(ctx), is_git_diff=True) diff --git a/cycode/cli/apps/scan/repository/repository_command.py b/cycode/cli/apps/scan/repository/repository_command.py index 045448e6..7d6b421a 100644 --- a/cycode/cli/apps/scan/repository/repository_command.py +++ b/cycode/cli/apps/scan/repository/repository_command.py @@ -63,7 +63,6 @@ def repository_command( perform_pre_scan_documents_actions(ctx, scan_type, documents_to_scan) logger.debug('Found all relevant files for scanning %s', {'path': path, 'branch': branch}) - scan_parameters = get_scan_parameters(ctx, (str(path),)) - scan_documents(ctx, documents_to_scan, scan_parameters=scan_parameters) + scan_documents(ctx, documents_to_scan, get_scan_parameters(ctx, (path,))) except Exception as e: handle_scan_exception(ctx, e) diff --git a/cycode/cli/printers/tables/sca_table_printer.py b/cycode/cli/printers/tables/sca_table_printer.py index b59a33ef..b77f55ed 100644 --- a/cycode/cli/printers/tables/sca_table_printer.py +++ b/cycode/cli/printers/tables/sca_table_printer.py @@ -1,5 +1,5 @@ from collections import defaultdict -from typing import TYPE_CHECKING, Dict, List +from typing import TYPE_CHECKING, Dict, List, Set, Tuple import typer @@ -37,9 +37,12 @@ def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: for policy_id, detections in detections_per_policy_id.items(): table = self._get_table(policy_id) - for detection in self._sort_and_group_detections(detections): + resulting_detections, group_separator_indexes = self._sort_and_group_detections(detections) + for detection in resulting_detections: self._enrich_table_with_values(policy_id, table, detection) + table.set_group_separator_indexes(group_separator_indexes) + self._print_summary_issues(len(detections), self._get_title(policy_id)) self._print_table(table) @@ -76,7 +79,7 @@ def __package_sort_key(detection: Detection) -> int: def _sort_detections_by_package(self, detections: List[Detection]) -> List[Detection]: return sorted(detections, key=self.__package_sort_key) - def _sort_and_group_detections(self, detections: List[Detection]) -> List[Detection]: + def _sort_and_group_detections(self, detections: List[Detection]) -> Tuple[List[Detection], Set[int]]: """Sort detections by severity and group by repository, code project and package name. Note: @@ -85,7 +88,8 @@ def _sort_and_group_detections(self, detections: List[Detection]) -> List[Detect Grouping by code projects also groups by ecosystem. Because manifest files are unique per ecosystem. """ - result = [] + resulting_detections = [] + group_separator_indexes = set() # we sort detections by package name to make persist output order sorted_detections = self._sort_detections_by_package(detections) @@ -96,9 +100,10 @@ def _sort_and_group_detections(self, detections: List[Detection]) -> List[Detect for code_project_group in grouped_by_code_project.values(): grouped_by_package = self.__group_by(code_project_group, 'package_name') for package_group in grouped_by_package.values(): - result.extend(self._sort_detections_by_severity(package_group)) + group_separator_indexes.add(len(resulting_detections) - 1) # indexing starts from 0 + resulting_detections.extend(self._sort_detections_by_severity(package_group)) - return result + return resulting_detections, group_separator_indexes def _get_table(self, policy_id: str) -> Table: table = Table() diff --git a/cycode/cli/printers/tables/table.py b/cycode/cli/printers/tables/table.py index a071c9b4..23022b2d 100644 --- a/cycode/cli/printers/tables/table.py +++ b/cycode/cli/printers/tables/table.py @@ -1,5 +1,5 @@ import urllib.parse -from typing import TYPE_CHECKING, Dict, List, Optional +from typing import TYPE_CHECKING, Dict, List, Optional, Set from rich.markup import escape from rich.table import Table as RichTable @@ -12,6 +12,8 @@ class Table: """Helper class to manage columns and their values in the right order and only if the column should be presented.""" def __init__(self, column_infos: Optional[List['ColumnInfo']] = None) -> None: + self._group_separator_indexes: Set[int] = set() + self._columns: Dict['ColumnInfo', List[str]] = {} if column_infos: self._columns = {columns: [] for columns in column_infos} @@ -35,6 +37,9 @@ def add_file_path_cell(self, column: 'ColumnInfo', path: str) -> None: escaped_path = escape(encoded_path) self._add_cell_no_error(column, f'[link file://{escaped_path}]{path}') + def set_group_separator_indexes(self, group_separator_indexes: Set[int]) -> None: + self._group_separator_indexes = group_separator_indexes + def _get_ordered_columns(self) -> List['ColumnInfo']: # we are sorting columns by index to make sure that columns will be printed in the right order return sorted(self._columns, key=lambda column_info: column_info.index) @@ -53,7 +58,7 @@ def get_table(self) -> 'RichTable': extra_args = column.column_opts if column.column_opts else {} table.add_column(header=column.name, overflow='fold', **extra_args) - for raw in self.get_rows(): - table.add_row(*raw) + for index, raw in enumerate(self.get_rows()): + table.add_row(*raw, end_section=index in self._group_separator_indexes) return table diff --git a/cycode/cli/printers/tables/table_printer.py b/cycode/cli/printers/tables/table_printer.py index 728e2baf..853e2465 100644 --- a/cycode/cli/printers/tables/table_printer.py +++ b/cycode/cli/printers/tables/table_printer.py @@ -1,5 +1,5 @@ from collections import defaultdict -from typing import TYPE_CHECKING, List, Tuple +from typing import TYPE_CHECKING, List, Set, Tuple from cycode.cli.cli_types import SeverityOption from cycode.cli.consts import SECRET_SCAN_TYPE @@ -18,12 +18,12 @@ SEVERITY_COLUMN = column_builder.build(name='Severity') ISSUE_TYPE_COLUMN = column_builder.build(name='Issue Type') FILE_PATH_COLUMN = column_builder.build(name='File Path', highlight=False) +LINE_NUMBER_COLUMN = column_builder.build(name='Line') +COLUMN_NUMBER_COLUMN = column_builder.build(name='Column') +VIOLATION_COLUMN = column_builder.build(name='Violation', highlight=False) +VIOLATION_LENGTH_COLUMN = column_builder.build(name='Length') SECRET_SHA_COLUMN = column_builder.build(name='Secret SHA') COMMIT_SHA_COLUMN = column_builder.build(name='Commit SHA') -LINE_NUMBER_COLUMN = column_builder.build(name='Line Number') -COLUMN_NUMBER_COLUMN = column_builder.build(name='Column Number') -VIOLATION_LENGTH_COLUMN = column_builder.build(name='Violation Length') -VIOLATION_COLUMN = column_builder.build(name='Violation', highlight=False) class TablePrinter(TablePrinterBase): @@ -37,9 +37,12 @@ def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: [(detection, document_detections.document) for detection in document_detections.detections] ) - for detection, document in self._sort_and_group_detections(detections_with_documents): + detections, group_separator_indexes = self._sort_and_group_detections(detections_with_documents) + for detection, document in detections: self._enrich_table_with_values(table, detection, document) + table.set_group_separator_indexes(group_separator_indexes) + self._print_table(table) self._print_report_urls(local_scan_results, self.ctx.obj.get('aggregation_report_url')) @@ -66,9 +69,10 @@ def _sort_detections_by_file_path( def _sort_and_group_detections( self, detections_with_documents: List[Tuple[Detection, Document]] - ) -> List[Tuple[Detection, Document]]: + ) -> Tuple[List[Tuple[Detection, Document]], Set[int]]: """Sort detections by severity and group by file name.""" - result = [] + detections = [] + group_separator_indexes = set() # we sort detections by file path to make persist output order sorted_detections = self._sort_detections_by_file_path(detections_with_documents) @@ -78,9 +82,10 @@ def _sort_and_group_detections( grouped_by_file_path[document.path].append((detection, document)) for file_path_group in grouped_by_file_path.values(): - result.extend(self._sort_detections_by_severity(file_path_group)) + group_separator_indexes.add(len(detections) - 1) # indexing starts from 0 + detections.extend(self._sort_detections_by_severity(file_path_group)) - return result + return detections, group_separator_indexes def _get_table(self) -> Table: table = Table() diff --git a/tests/test_code_scanner.py b/tests/test_code_scanner.py index 709fe70e..d16aad82 100644 --- a/tests/test_code_scanner.py +++ b/tests/test_code_scanner.py @@ -29,7 +29,7 @@ def test_is_relevant_file_to_scan_sca() -> None: @pytest.mark.parametrize('scan_type', list(ScanTypeOption)) def test_try_get_report_url_if_needed_return_none(scan_type: ScanTypeOption, scan_client: ScanClient) -> None: scan_id = uuid4().hex - result = _try_get_report_url_if_needed(scan_client, False, scan_id, consts.SECRET_SCAN_TYPE) + result = _try_get_report_url_if_needed(scan_client, scan_id, consts.SECRET_SCAN_TYPE, scan_parameters={}) assert result is None @@ -44,7 +44,7 @@ def test_try_get_report_url_if_needed_return_result( responses.add(get_scan_report_url_response(url, scan_id)) scan_report_url_response = scan_client.get_scan_report_url(str(scan_id), scan_type) - result = _try_get_report_url_if_needed(scan_client, True, str(scan_id), scan_type) + result = _try_get_report_url_if_needed(scan_client, str(scan_id), scan_type, scan_parameters={'report': True}) assert result == scan_report_url_response.report_url From f4ae0fae56f9539d21f9eeb47cd3aff04a655a74 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 26 Mar 2025 12:14:44 +0100 Subject: [PATCH 152/257] CM-45719 - Add syntax highlight for code snippets in text output (#290) --- cycode/cli/printers/printer_base.py | 1 - cycode/cli/printers/text_printer.py | 222 ++++++++++-------------- cycode/cli/utils/progress_bar.py | 7 +- tests/cli/commands/test_main_command.py | 2 +- 4 files changed, 90 insertions(+), 142 deletions(-) diff --git a/cycode/cli/printers/printer_base.py b/cycode/cli/printers/printer_base.py index ee9a7793..633a2ccc 100644 --- a/cycode/cli/printers/printer_base.py +++ b/cycode/cli/printers/printer_base.py @@ -17,7 +17,6 @@ class PrinterBase(ABC): RED_COLOR_NAME = 'red' - WHITE_COLOR_NAME = 'white' GREEN_COLOR_NAME = 'green' def __init__(self, ctx: typer.Context) -> None: diff --git a/cycode/cli/printers/text_printer.py b/cycode/cli/printers/text_printer.py index 7828d909..73eaccc2 100644 --- a/cycode/cli/printers/text_printer.py +++ b/cycode/cli/printers/text_printer.py @@ -1,9 +1,13 @@ import math +import urllib.parse from typing import TYPE_CHECKING, Dict, List, Optional -import click import typer +from rich.console import Console +from rich.markup import escape +from rich.syntax import Syntax +from cycode.cli.cli_types import SeverityOption from cycode.cli.consts import COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES, SECRET_SCAN_TYPE from cycode.cli.models import CliError, CliResult, Detection, Document, DocumentDetections from cycode.cli.printers.printer_base import PrinterBase @@ -25,28 +29,28 @@ def print_result(self, result: CliResult) -> None: if not result.success: color = self.RED_COLOR_NAME - click.secho(result.message, fg=color) + typer.secho(result.message, fg=color) if not result.data: return - click.secho('\nAdditional data:', fg=color) + typer.secho('\nAdditional data:', fg=color) for name, value in result.data.items(): - click.secho(f'- {name}: {value}', fg=color) + typer.secho(f'- {name}: {value}', fg=color) def print_error(self, error: CliError) -> None: - click.secho(error.message, fg=self.RED_COLOR_NAME) + typer.secho(error.message, fg=self.RED_COLOR_NAME) def print_scan_results( self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None ) -> None: if not errors and all(result.issue_detected == 0 for result in local_scan_results): - click.secho('Good job! No issues were found!!! 👏👏👏', fg=self.GREEN_COLOR_NAME) + typer.secho('Good job! No issues were found!!! 👏👏👏', fg=self.GREEN_COLOR_NAME) return for local_scan_result in local_scan_results: for document_detections in local_scan_result.document_detections: - self._print_document_detections(document_detections, local_scan_result.scan_id) + self._print_document_detections(document_detections) report_urls = [scan_result.report_url for scan_result in local_scan_results if scan_result.report_url] @@ -54,44 +58,51 @@ def print_scan_results( if not errors: return - click.secho( + typer.secho( 'Unfortunately, Cycode was unable to complete the full scan. ' 'Please note that not all results may be available:', fg='red', ) for scan_id, error in errors.items(): - click.echo(f'- {scan_id}: ', nl=False) + typer.echo(f'- {scan_id}: ', nl=False) self.print_error(error) - def _print_document_detections(self, document_detections: DocumentDetections, scan_id: str) -> None: + def _print_document_detections(self, document_detections: DocumentDetections) -> None: document = document_detections.document for detection in document_detections.detections: - self._print_detection_summary(detection, document.path, scan_id) + self._print_detection_summary(detection, document.path) + self._print_new_line() self._print_detection_code_segment(detection, document) + self._print_new_line() - def _print_detection_summary(self, detection: Detection, document_path: str, scan_id: str) -> None: + @staticmethod + def _print_new_line() -> None: + typer.echo() + + def _print_detection_summary(self, detection: Detection, document_path: str) -> None: detection_name = detection.type if self.scan_type == SECRET_SCAN_TYPE else detection.message - detection_name_styled = click.style(detection_name, fg='bright_red', bold=True) - detection_sha = detection.detection_details.get('sha512') - detection_sha_message = f'\nSecret SHA: {detection_sha}' if detection_sha else '' + detection_severity = detection.severity or 'N/A' + detection_severity_color = SeverityOption.get_member_color(detection_severity) + detection_severity = f'[{detection_severity_color}]{detection_severity.upper()}[/{detection_severity_color}]' + + escaped_document_path = escape(urllib.parse.quote(document_path)) + clickable_document_path = f'[link file://{escaped_document_path}]{document_path}' - scan_id_message = f'\nScan ID: {scan_id}' detection_commit_id = detection.detection_details.get('commit_id') detection_commit_id_message = f'\nCommit SHA: {detection_commit_id}' if detection_commit_id else '' company_guidelines = detection.detection_details.get('custom_remediation_guidelines') company_guidelines_message = f'\nCompany Guideline: {company_guidelines}' if company_guidelines else '' - click.echo( - f'⛔ ' - f'Found issue of type: {detection_name_styled} ' - f'(rule ID: {detection.detection_rule_id}) in file: {click.format_filename(document_path)} ' - f'{detection_sha_message}' - f'{scan_id_message}' + Console().print( + f':no_entry: ' + f'Found {detection_severity} issue of type: [bright_red][bold]{detection_name}[/bold][/bright_red] ' + f'in file: {clickable_document_path} ' f'{detection_commit_id_message}' f'{company_guidelines_message}' - f' ⛔' + f' :no_entry:', + highlight=True, ) def _print_detection_code_segment( @@ -109,145 +120,86 @@ def _print_report_urls(report_urls: List[str], aggregation_report_url: Optional[ if not report_urls and not aggregation_report_url: return if aggregation_report_url: - click.echo(f'Report URL: {aggregation_report_url}') + typer.echo(f'Report URL: {aggregation_report_url}') return - click.echo('Report URLs:') + typer.echo('Report URLs:') for report_url in report_urls: - click.echo(f'- {report_url}') + typer.echo(f'- {report_url}') @staticmethod def _get_code_segment_start_line(detection_line: int, lines_to_display: int) -> int: start_line = detection_line - math.ceil(lines_to_display / 2) return 0 if start_line < 0 else start_line - def _print_line_of_code_segment( - self, - document: Document, - line: str, - line_number: int, - detection_position_in_line: int, - violation_length: int, - is_detection_line: bool, - ) -> None: - if is_detection_line: - self._print_detection_line(document, line, line_number, detection_position_in_line, violation_length) - else: - self._print_line(document, line, line_number) - - def _print_detection_line( - self, document: Document, line: str, line_number: int, detection_position_in_line: int, violation_length: int - ) -> None: - detection_line = self._get_detection_line_style( - line, document.is_git_diff_format, detection_position_in_line, violation_length - ) - - click.echo(f'{self._get_line_number_style(line_number)} {detection_line}') - - def _print_line(self, document: Document, line: str, line_number: int) -> None: - line_no = self._get_line_number_style(line_number) - line = self._get_line_style(line, document.is_git_diff_format) - - click.echo(f'{line_no} {line}') - - def _get_detection_line_style(self, line: str, is_git_diff: bool, start_position: int, length: int) -> str: - line_color = self._get_line_color(line, is_git_diff) - if self.scan_type != SECRET_SCAN_TYPE or start_position < 0 or length < 0: - return self._get_line_style(line, is_git_diff, line_color) - - violation = line[start_position : start_position + length] - if not self.show_secret: - violation = obfuscate_text(violation) - - line_to_violation = line[0:start_position] - line_from_violation = line[start_position + length :] - - return ( - f'{self._get_line_style(line_to_violation, is_git_diff, line_color)}' - f'{self._get_line_style(violation, is_git_diff, line_color, underline=True)}' - f'{self._get_line_style(line_from_violation, is_git_diff, line_color)}' - ) - - def _get_line_style( - self, line: str, is_git_diff: bool, color: Optional[str] = None, underline: bool = False - ) -> str: - if color is None: - color = self._get_line_color(line, is_git_diff) - - return click.style(line, fg=color, bold=False, underline=underline) - - def _get_line_color(self, line: str, is_git_diff: bool) -> str: - if not is_git_diff: - return self.WHITE_COLOR_NAME - - if line.startswith('+'): - return self.GREEN_COLOR_NAME - - if line.startswith('-'): - return self.RED_COLOR_NAME - - return self.WHITE_COLOR_NAME - - def _get_line_number_style(self, line_number: int) -> str: + def _get_detection_line(self, detection: Detection) -> int: return ( - f'{click.style(str(line_number), fg=self.WHITE_COLOR_NAME, bold=False)} ' - f'{click.style("|", fg=self.RED_COLOR_NAME, bold=False)}' + detection.detection_details.get('line', -1) + if self.scan_type == SECRET_SCAN_TYPE + else detection.detection_details.get('line_in_file', -1) - 1 ) def _print_detection_from_file(self, detection: Detection, document: Document, lines_to_display: int) -> None: detection_details = detection.detection_details - detection_line = ( - detection_details.get('line', -1) - if self.scan_type == SECRET_SCAN_TYPE - else detection_details.get('line_in_file', -1) - ) - detection_position = detection_details.get('start_position', -1) + detection_line = self._get_detection_line(detection) + start_line_index = self._get_code_segment_start_line(detection_line, lines_to_display) + detection_position = get_position_in_line(document.content, detection_details.get('start_position', -1)) violation_length = detection_details.get('length', -1) - file_content = document.content - file_lines = file_content.splitlines() - start_line = self._get_code_segment_start_line(detection_line, lines_to_display) - detection_position_in_line = get_position_in_line(file_content, detection_position) - - click.echo() + code_lines_to_render = [] + document_content_lines = document.content.splitlines() for line_index in range(lines_to_display): - current_line_index = start_line + line_index - if current_line_index >= len(file_lines): + current_line_index = start_line_index + line_index + if current_line_index >= len(document_content_lines): break - current_line = file_lines[current_line_index] - is_detection_line = current_line_index == detection_line - self._print_line_of_code_segment( - document, - current_line, - current_line_index + 1, - detection_position_in_line, - violation_length, - is_detection_line, + line_content = document_content_lines[current_line_index] + + line_with_detection = current_line_index == detection_line + if self.scan_type == SECRET_SCAN_TYPE and line_with_detection and not self.show_secret: + violation = line_content[detection_position : detection_position + violation_length] + code_lines_to_render.append(line_content.replace(violation, obfuscate_text(violation))) + else: + code_lines_to_render.append(line_content) + + code_to_render = '\n'.join(code_lines_to_render) + Console().print( + Syntax( + code=code_to_render, + lexer=Syntax.guess_lexer(document.path, code=code_to_render), + line_numbers=True, + dedent=True, + tab_size=2, + start_line=start_line_index + 1, + highlight_lines={ + detection_line + 1, + }, ) - click.echo() + ) def _print_detection_from_git_diff(self, detection: Detection, document: Document) -> None: detection_details = detection.detection_details - detection_line_number = detection_details.get('line', -1) - detection_line_number_in_original_file = detection_details.get('line_in_file', -1) + detection_line = self._get_detection_line(detection) detection_position = detection_details.get('start_position', -1) violation_length = detection_details.get('length', -1) - git_diff_content = document.content - git_diff_lines = git_diff_content.splitlines() - detection_line = git_diff_lines[detection_line_number] - detection_position_in_line = get_position_in_line(git_diff_content, detection_position) - - click.echo() - self._print_detection_line( - document, - detection_line, - detection_line_number_in_original_file, - detection_position_in_line, - violation_length, + line_content = document.content.splitlines()[detection_line] + detection_position_in_line = get_position_in_line(document.content, detection_position) + if self.scan_type == SECRET_SCAN_TYPE and not self.show_secret: + violation = line_content[detection_position_in_line : detection_position_in_line + violation_length] + line_content = line_content.replace(violation, obfuscate_text(violation)) + + Console().print( + Syntax( + line_content, + lexer='diff', + line_numbers=True, + start_line=detection_line, + dedent=True, + tab_size=2, + highlight_lines={detection_line + 1}, + ) ) - click.echo() def _is_git_diff_based_scan(self) -> bool: return self.command_scan_type in COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES and self.scan_type == SECRET_SCAN_TYPE diff --git a/cycode/cli/utils/progress_bar.py b/cycode/cli/utils/progress_bar.py index 90a19801..e0fec5aa 100644 --- a/cycode/cli/utils/progress_bar.py +++ b/cycode/cli/utils/progress_bar.py @@ -145,10 +145,7 @@ def __init__(self, progress_bar_sections: ProgressBarSections) -> None: self._current_section: ProgressBarSectionInfo = _get_initial_section(self._progress_bar_sections) self._current_right_side_label = '' - self._progress_bar = Progress( - *_PROGRESS_BAR_COLUMNS, - transient=True, - ) + self._progress_bar = Progress(*_PROGRESS_BAR_COLUMNS) self._progress_bar_task_id = self._progress_bar.add_task( description=self._current_section.label, total=_PROGRESS_BAR_LENGTH, @@ -245,7 +242,7 @@ def update(self, section: 'ProgressBarSection', value: int = 1) -> None: self._maybe_update_current_section() def update_right_side_label(self, label: Optional[str] = None) -> None: - self._current_right_side_label = f'({label})' or '' + self._current_right_side_label = f'({label})' if label else '' self._progress_bar_update() diff --git a/tests/cli/commands/test_main_command.py b/tests/cli/commands/test_main_command.py index d7575ddb..04bc3e01 100644 --- a/tests/cli/commands/test_main_command.py +++ b/tests/cli/commands/test_main_command.py @@ -49,7 +49,7 @@ def test_passing_output_option(output: str, scan_client: 'ScanClient', api_token output = json.loads(result.output) assert 'scan_id' in output else: - assert 'Scan ID' in result.output + assert 'issue of type:' in result.output @responses.activate From ba16609d2384cb5d91009fb2b857707007108eb3 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 1 Apr 2025 15:49:26 +0200 Subject: [PATCH 153/257] CM-46371 - Add retry behavior for HTTP requests (#291) --- cycode/cli/apps/auth/auth_manager.py | 14 +------ cycode/cyclient/auth_client.py | 9 ++++- cycode/cyclient/config.py | 8 ++++ cycode/cyclient/cycode_client_base.py | 54 ++++++++++++++++++++++++++- poetry.lock | 18 ++++++++- pyproject.toml | 1 + 6 files changed, 88 insertions(+), 16 deletions(-) diff --git a/cycode/cli/apps/auth/auth_manager.py b/cycode/cli/apps/auth/auth_manager.py index ee064f3c..2652bfe1 100644 --- a/cycode/cli/apps/auth/auth_manager.py +++ b/cycode/cli/apps/auth/auth_manager.py @@ -2,8 +2,6 @@ import webbrowser from typing import TYPE_CHECKING, Tuple -from requests import Request - from cycode.cli.exceptions.custom_exceptions import AuthProcessError from cycode.cli.user_settings.configuration_manager import ConfigurationManager from cycode.cli.user_settings.credentials_manager import CredentialsManager @@ -53,7 +51,7 @@ def start_session(self, code_challenge: str) -> str: return auth_session.session_id def redirect_to_login_page(self, code_challenge: str, session_id: str) -> None: - login_url = self._build_login_url(code_challenge, session_id) + login_url = self.auth_client.build_login_url(code_challenge, session_id) webbrowser.open(login_url) def get_api_token(self, session_id: str, code_verifier: str) -> 'ApiToken': @@ -75,19 +73,11 @@ def get_api_token_polling(self, session_id: str, code_verifier: str) -> 'ApiToke raise AuthProcessError('Error while obtaining API token') time.sleep(self.POLLING_WAIT_INTERVAL_IN_SECONDS) - raise AuthProcessError('session expired') + raise AuthProcessError('Timeout while obtaining API token (session expired)') def save_api_token(self, api_token: 'ApiToken') -> None: self.credentials_manager.update_credentials(api_token.client_id, api_token.secret) - def _build_login_url(self, code_challenge: str, session_id: str) -> str: - app_url = self.configuration_manager.get_cycode_app_url() - login_url = f'{app_url}/account/sign-in' - query_params = {'source': 'cycode_cli', 'code_challenge': code_challenge, 'session_id': session_id} - # TODO(MarshalX). Use auth_client instead and don't depend on "requests" lib here - request = Request(url=login_url, params=query_params) - return request.prepare().url - def _generate_pkce_code_pair(self) -> Tuple[str, str]: code_verifier = generate_random_string(self.CODE_VERIFIER_LENGTH) code_challenge = hash_string_to_sha256(code_verifier) diff --git a/cycode/cyclient/auth_client.py b/cycode/cyclient/auth_client.py index 20c80d13..1df7ad9b 100644 --- a/cycode/cyclient/auth_client.py +++ b/cycode/cyclient/auth_client.py @@ -1,9 +1,9 @@ from typing import Optional -from requests import Response +from requests import Request, Response from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError, RequestHttpError -from cycode.cyclient import models +from cycode.cyclient import config, models from cycode.cyclient.cycode_client import CycodeClient @@ -13,6 +13,11 @@ class AuthClient: def __init__(self) -> None: self.cycode_client = CycodeClient() + @staticmethod + def build_login_url(code_challenge: str, session_id: str) -> str: + query_params = {'source': 'cycode_cli', 'code_challenge': code_challenge, 'session_id': session_id} + return Request(url=f'{config.cycode_app_url}/account/sign-in', params=query_params).prepare().url + def start_session(self, code_challenge: str) -> models.AuthenticationSession: path = f'{self.AUTH_CONTROLLER_PATH}/start' body = {'code_challenge': code_challenge} diff --git a/cycode/cyclient/config.py b/cycode/cyclient/config.py index 2b278bf4..ec21efb4 100644 --- a/cycode/cyclient/config.py +++ b/cycode/cyclient/config.py @@ -14,6 +14,14 @@ cycode_api_url = consts.DEFAULT_CYCODE_API_URL +cycode_app_url = configuration_manager.get_cycode_app_url() +if not is_valid_url(cycode_app_url): + logger.warning( + 'Invalid Cycode APP URL: %s, using default value (%s)', cycode_app_url, consts.DEFAULT_CYCODE_APP_URL + ) + cycode_app_url = consts.DEFAULT_CYCODE_APP_URL + + def _is_on_premise_installation(cycode_domain: str) -> bool: return not cycode_api_url.endswith(cycode_domain) diff --git a/cycode/cyclient/cycode_client_base.py b/cycode/cyclient/cycode_client_base.py index f2eb77bf..37e9d4f6 100644 --- a/cycode/cyclient/cycode_client_base.py +++ b/cycode/cyclient/cycode_client_base.py @@ -1,11 +1,12 @@ import os import platform import ssl -from typing import Callable, ClassVar, Dict, Optional +from typing import TYPE_CHECKING, Callable, ClassVar, Dict, Optional import requests from requests import Response, exceptions from requests.adapters import HTTPAdapter +from tenacity import retry, retry_if_exception, stop_after_attempt, wait_random_exponential from cycode.cli.exceptions.custom_exceptions import ( HttpUnauthorizedError, @@ -19,6 +20,9 @@ from cycode.cyclient.headers import get_cli_user_agent, get_correlation_id from cycode.cyclient.logger import logger +if TYPE_CHECKING: + from tenacity import RetryCallState + class SystemStorageSslContext(HTTPAdapter): def init_poolmanager(self, *args, **kwargs) -> None: @@ -45,6 +49,47 @@ def _get_request_function() -> Callable: return session.request +_REQUEST_ERRORS_TO_RETRY = ( + RequestTimeout, + RequestConnectionError, + exceptions.ChunkedEncodingError, + exceptions.ContentDecodingError, +) +_RETRY_MAX_ATTEMPTS = 3 +_RETRY_STOP_STRATEGY = stop_after_attempt(_RETRY_MAX_ATTEMPTS) +_RETRY_WAIT_STRATEGY = wait_random_exponential(multiplier=1, min=2, max=10) + + +def _retry_before_sleep(retry_state: 'RetryCallState') -> None: + exception_name = 'None' + if retry_state.outcome.failed: + exception = retry_state.outcome.exception() + exception_name = f'{exception.__class__.__name__}' + + logger.debug( + 'Retrying request after error: %s. Attempt %s of %s. Upcoming sleep: %s', + exception_name, + retry_state.attempt_number, + _RETRY_MAX_ATTEMPTS, + retry_state.upcoming_sleep, + ) + + +def _should_retry_exception(exception: BaseException) -> bool: + if 'PYTEST_CURRENT_TEST' in os.environ: + # We are running under pytest, don't retry + return False + + # Don't retry client errors (400, 401, etc.) + if isinstance(exception, RequestHttpError): + return not exception.status_code < 500 + + is_request_error = isinstance(exception, _REQUEST_ERRORS_TO_RETRY) + is_server_error = isinstance(exception, RequestHttpError) and exception.status_code >= 500 + + return is_request_error or is_server_error + + class CycodeClientBase: MANDATORY_HEADERS: ClassVar[Dict[str, str]] = { 'User-Agent': get_cli_user_agent(), @@ -72,6 +117,13 @@ def put(self, url_path: str, body: Optional[dict] = None, headers: Optional[dict def get(self, url_path: str, headers: Optional[dict] = None, **kwargs) -> Response: return self._execute(method='get', endpoint=url_path, headers=headers, **kwargs) + @retry( + retry=retry_if_exception(_should_retry_exception), + stop=_RETRY_STOP_STRATEGY, + wait=_RETRY_WAIT_STRATEGY, + reraise=True, + before_sleep=_retry_before_sleep, + ) def _execute( self, method: str, diff --git a/poetry.lock b/poetry.lock index 186fee3f..d0b6503d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -980,6 +980,22 @@ files = [ {file = "smmap-5.0.1.tar.gz", hash = "sha256:dceeb6c0028fdb6734471eb07c0cd2aae706ccaecab45965ee83f11c8d3b1f62"}, ] +[[package]] +name = "tenacity" +version = "9.0.0" +description = "Retry code until it succeeds" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "tenacity-9.0.0-py3-none-any.whl", hash = "sha256:93de0c98785b27fcf659856aa9f54bfbd399e29969b0621bc7f762bd441b4539"}, + {file = "tenacity-9.0.0.tar.gz", hash = "sha256:807f37ca97d62aa361264d497b0e31e92b8027044942bfa756160d908320d73b"}, +] + +[package.extras] +doc = ["reno", "sphinx"] +test = ["pytest", "tornado (>=4.5)", "typeguard"] + [[package]] name = "tomli" version = "2.2.1" @@ -1118,4 +1134,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9,<3.14" -content-hash = "2c45abb3ea36096f8dbe862ba8bf5e064994ed9cdf86a8256643b9822c40168f" +content-hash = "590be7f6a392d52a8d298596ef95e6ee664a8a3515530b01d727fe268e15fb0d" diff --git a/pyproject.toml b/pyproject.toml index ef555019..cde794b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ pyjwt = ">=2.8.0,<3.0" rich = ">=13.9.4, <14" patch-ng = "1.18.1" typer = "^0.15.2" +tenacity = ">=9.0.0,<9.1.0" [tool.poetry.group.test.dependencies] mock = ">=4.0.3,<4.1.0" From 2901f82d5b7cc7f1ed53d3b9c904251677e6a6ec Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Fri, 4 Apr 2025 15:12:38 +0200 Subject: [PATCH 154/257] CM-46426 - Fix severity for SCA (use Cycode severity instead of Advisory Severity) (#292) --- cycode/cli/commands/scan/code_scanner.py | 4 +--- cycode/cli/printers/tables/sca_table_printer.py | 7 +++---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/cycode/cli/commands/scan/code_scanner.py b/cycode/cli/commands/scan/code_scanner.py index 0dbf63ea..478b8ffe 100644 --- a/cycode/cli/commands/scan/code_scanner.py +++ b/cycode/cli/commands/scan/code_scanner.py @@ -695,9 +695,7 @@ def exclude_irrelevant_detections( def _exclude_detections_by_severity(detections: List[Detection], severity_threshold: str) -> List[Detection]: relevant_detections = [] for detection in detections: - severity = detection.detection_details.get('advisory_severity') - if not severity: - severity = detection.severity + severity = detection.severity if _does_severity_match_severity_threshold(severity, severity_threshold): relevant_detections.append(detection) diff --git a/cycode/cli/printers/tables/sca_table_printer.py b/cycode/cli/printers/tables/sca_table_printer.py index 5a6ec726..e92b2be7 100644 --- a/cycode/cli/printers/tables/sca_table_printer.py +++ b/cycode/cli/printers/tables/sca_table_printer.py @@ -72,9 +72,8 @@ def __group_by(detections: List[Detection], details_field_name: str) -> Dict[str @staticmethod def __severity_sort_key(detection: Detection) -> int: - severity = detection.detection_details.get('advisory_severity') - if severity: - return Severity.get_member_weight(severity) + if detection.severity: + return Severity.get_member_weight(detection.severity) return SEVERITY_UNKNOWN_WEIGHT @@ -138,7 +137,7 @@ def _get_table(self, policy_id: str) -> Table: def _enrich_table_with_values(table: Table, detection: Detection) -> None: detection_details = detection.detection_details - table.set(SEVERITY_COLUMN, detection_details.get('advisory_severity')) + table.set(SEVERITY_COLUMN, detection.severity) table.set(REPOSITORY_COLUMN, detection_details.get('repository_name')) table.set(CODE_PROJECT_COLUMN, detection_details.get('file_name')) From 355d1c05bf6110c05fc35985acbf8d42c2afe460 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 8 Apr 2025 13:20:19 +0200 Subject: [PATCH 155/257] CM-46563 - Migrate to rich Console, add help rich panels, add syntax highlighting for light themes, fix progress bar, fix traceback printing, fix repository scan (#293) --- cycode/cli/app.py | 31 ++++++++++- cycode/cli/apps/ai_remediation/__init__.py | 5 +- .../apps/ai_remediation/print_remediation.py | 4 +- .../cli/apps/configure/configure_command.py | 7 +-- cycode/cli/apps/ignore/ignore_command.py | 39 +++++++++---- cycode/cli/apps/report/sbom/__init__.py | 5 +- cycode/cli/apps/report/sbom/sbom_command.py | 4 ++ .../cli/apps/report/sbom/sbom_report_file.py | 8 ++- cycode/cli/apps/scan/__init__.py | 12 ++-- cycode/cli/apps/scan/code_scanner.py | 9 +-- .../scan/repository/repository_command.py | 2 +- .../cli/apps/scan/scan_ci/ci_integrations.py | 6 +- cycode/cli/apps/scan/scan_command.py | 15 +++-- cycode/cli/apps/status/status_command.py | 8 +-- cycode/cli/apps/status/version_command.py | 11 +--- cycode/cli/cli_types.py | 13 +++++ cycode/cli/console.py | 47 ++++++++++++++++ cycode/cli/printers/json_printer.py | 12 ++-- cycode/cli/printers/printer_base.py | 31 +++++++---- .../cli/printers/tables/sca_table_printer.py | 13 ++--- cycode/cli/printers/tables/table_printer.py | 2 +- .../cli/printers/tables/table_printer_base.py | 20 +++---- cycode/cli/printers/text_printer.py | 55 +++++++++---------- cycode/cli/utils/progress_bar.py | 11 ++-- cycode/cli/utils/shell_executor.py | 3 +- cycode/cli/utils/version_checker.py | 14 ++--- cycode/logger.py | 5 +- tests/cli/commands/test_main_command.py | 2 +- .../cli/exceptions/test_handle_scan_errors.py | 17 ++++-- 29 files changed, 264 insertions(+), 147 deletions(-) create mode 100644 cycode/cli/console.py diff --git a/cycode/cli/app.py b/cycode/cli/app.py index 96389ef1..80742bab 100644 --- a/cycode/cli/app.py +++ b/cycode/cli/app.py @@ -2,6 +2,7 @@ from typing import Annotated, Optional import typer +from typer.completion import install_callback, show_callback from cycode import __version__ from cycode.cli.apps import ai_remediation, auth, configure, ignore, report, scan, status @@ -20,6 +21,7 @@ pretty_exceptions_short=True, context_settings=CLI_CONTEXT_SETTINGS, rich_markup_mode='rich', + add_completion=False, # we add it manually to control the rich help panel ) app.add_typer(ai_remediation.app) @@ -39,9 +41,10 @@ def check_latest_version_on_close(ctx: typer.Context) -> None: # we always want to check the latest version for "version" and "status" commands should_use_cache = ctx.invoked_subcommand not in {'version', 'status'} - version_checker.check_and_notify_update( - current_version=__version__, use_color=ctx.color, use_cache=should_use_cache - ) + version_checker.check_and_notify_update(current_version=__version__, use_cache=should_use_cache) + + +_COMPLETION_RICH_HELP_PANEL = 'Completion options' @app.callback() @@ -61,6 +64,28 @@ def app_callback( Optional[str], typer.Option(hidden=True, help='Characteristic JSON object that lets servers identify the application.'), ] = None, + _: Annotated[ + Optional[bool], + typer.Option( + '--install-completion', + callback=install_callback, + is_eager=True, + expose_value=False, + help='Install completion for the current shell.', + rich_help_panel=_COMPLETION_RICH_HELP_PANEL, + ), + ] = False, + __: Annotated[ + Optional[bool], + typer.Option( + '--show-completion', + callback=show_callback, + is_eager=True, + expose_value=False, + help='Show completion for the current shell, to copy it or customize the installation.', + rich_help_panel=_COMPLETION_RICH_HELP_PANEL, + ), + ] = False, ) -> None: init_sentry() add_breadcrumb('cycode') diff --git a/cycode/cli/apps/ai_remediation/__init__.py b/cycode/cli/apps/ai_remediation/__init__.py index 2ccba382..6b5a3013 100644 --- a/cycode/cli/apps/ai_remediation/__init__.py +++ b/cycode/cli/apps/ai_remediation/__init__.py @@ -3,4 +3,7 @@ from cycode.cli.apps.ai_remediation.ai_remediation_command import ai_remediation_command app = typer.Typer() -app.command(name='ai_remediation', short_help='Get AI remediation (INTERNAL).', hidden=True)(ai_remediation_command) +app.command(name='ai-remediation', short_help='Get AI remediation (INTERNAL).', hidden=True)(ai_remediation_command) + +# backward compatibility +app.command(hidden=True, name='ai_remediation')(ai_remediation_command) diff --git a/cycode/cli/apps/ai_remediation/print_remediation.py b/cycode/cli/apps/ai_remediation/print_remediation.py index c706c13f..c0109341 100644 --- a/cycode/cli/apps/ai_remediation/print_remediation.py +++ b/cycode/cli/apps/ai_remediation/print_remediation.py @@ -1,7 +1,7 @@ import typer -from rich.console import Console from rich.markdown import Markdown +from cycode.cli.console import console from cycode.cli.models import CliResult from cycode.cli.printers import ConsolePrinter @@ -12,4 +12,4 @@ def print_remediation(ctx: typer.Context, remediation_markdown: str, is_fix_avai data = {'remediation': remediation_markdown, 'is_fix_available': is_fix_available} printer.print_result(CliResult(success=True, message='Remediation fetched successfully', data=data)) else: # text or table - Console().print(Markdown(remediation_markdown)) + console.print(Markdown(remediation_markdown)) diff --git a/cycode/cli/apps/configure/configure_command.py b/cycode/cli/apps/configure/configure_command.py index 9c631641..2aa86a8f 100644 --- a/cycode/cli/apps/configure/configure_command.py +++ b/cycode/cli/apps/configure/configure_command.py @@ -1,7 +1,5 @@ from typing import Optional -import typer - from cycode.cli.apps.configure.consts import CONFIGURATION_MANAGER, CREDENTIALS_MANAGER from cycode.cli.apps.configure.messages import get_credentials_update_result_message, get_urls_update_result_message from cycode.cli.apps.configure.prompts import ( @@ -10,6 +8,7 @@ get_client_id_input, get_client_secret_input, ) +from cycode.cli.console import console from cycode.cli.utils.sentry import add_breadcrumb @@ -52,6 +51,6 @@ def configure_command() -> None: CREDENTIALS_MANAGER.update_credentials(client_id, client_secret) if config_updated: - typer.echo(get_urls_update_result_message()) + console.print(get_urls_update_result_message()) if credentials_updated: - typer.echo(get_credentials_update_result_message()) + console.print(get_credentials_update_result_message()) diff --git a/cycode/cli/apps/ignore/ignore_command.py b/cycode/cli/apps/ignore/ignore_command.py index 47d4fa0d..cc6ecd25 100644 --- a/cycode/cli/apps/ignore/ignore_command.py +++ b/cycode/cli/apps/ignore/ignore_command.py @@ -12,45 +12,62 @@ from cycode.cli.utils.sentry import add_breadcrumb from cycode.cli.utils.string_utils import hash_string_to_sha256 +_FILTER_BY_RICH_HELP_PANEL = 'Filter options' +_SECRETS_FILTER_BY_RICH_HELP_PANEL = 'Secrets filter options' +_SCA_FILTER_BY_RICH_HELP_PANEL = 'SCA filter options' + def _is_package_pattern_valid(package: str) -> bool: return re.search('^[^@]+@[^@]+$', package) is not None def ignore_command( # noqa: C901 - by_value: Annotated[ - Optional[str], typer.Option(help='Ignore a specific value while scanning for Secrets.', show_default=False) + by_path: Annotated[ + Optional[str], + typer.Option( + help='Ignore a specific file or directory while scanning.', + show_default=False, + rich_help_panel=_FILTER_BY_RICH_HELP_PANEL, + ), ] = None, - by_sha: Annotated[ + by_rule: Annotated[ Optional[str], typer.Option( - help='Ignore a specific SHA512 representation of a string while scanning for Secrets.', show_default=False + help='Ignore scanning a specific Secrets rule ID or IaC rule ID.', + show_default=False, + rich_help_panel=_FILTER_BY_RICH_HELP_PANEL, ), ] = None, - by_path: Annotated[ + by_value: Annotated[ Optional[str], - typer.Option(help='Avoid scanning a specific path. You`ll need to specify the scan type.', show_default=False), + typer.Option( + help='Ignore a specific value.', + show_default=False, + rich_help_panel=_SECRETS_FILTER_BY_RICH_HELP_PANEL, + ), ] = None, - by_rule: Annotated[ + by_sha: Annotated[ Optional[str], typer.Option( - help='Ignore scanning a specific secret rule ID or IaC rule ID. You`ll to specify the scan type.', + help='Ignore a specific SHA512 representation of a string.', show_default=False, + rich_help_panel=_SECRETS_FILTER_BY_RICH_HELP_PANEL, ), ] = None, by_package: Annotated[ Optional[str], typer.Option( - help='Ignore scanning a specific package version while running an SCA scan. ' - 'Expected pattern: name@version.', + help='Ignore scanning a specific package version. Expected pattern: [cyan]name@version[/cyan].', show_default=False, + rich_help_panel=_SCA_FILTER_BY_RICH_HELP_PANEL, ), ] = None, by_cve: Annotated[ Optional[str], typer.Option( - help='Ignore scanning a specific CVE while running an SCA scan. Expected pattern: CVE-YYYY-NNN.', + help='Ignore scanning a specific CVE. Expected pattern: [cyan]CVE-YYYY-NNN[/cyan].', show_default=False, + rich_help_panel=_SCA_FILTER_BY_RICH_HELP_PANEL, ), ] = None, scan_type: Annotated[ diff --git a/cycode/cli/apps/report/sbom/__init__.py b/cycode/cli/apps/report/sbom/__init__.py index 461b3fe0..77d081e8 100644 --- a/cycode/cli/apps/report/sbom/__init__.py +++ b/cycode/cli/apps/report/sbom/__init__.py @@ -7,6 +7,9 @@ app = typer.Typer(name='sbom') app.callback(short_help='Generate SBOM report for remote repository by url or local directory by path.')(sbom_command) app.command(name='path', short_help='Generate SBOM report for provided path in the command.')(path_command) -app.command(name='repository_url', short_help='Generate SBOM report for provided repository URI in the command.')( +app.command(name='repository-url', short_help='Generate SBOM report for provided repository URI in the command.')( repository_url_command ) + +# backward compatibility +app.command(hidden=True, name='repository_url')(repository_url_command) diff --git a/cycode/cli/apps/report/sbom/sbom_command.py b/cycode/cli/apps/report/sbom/sbom_command.py index 65dc3fd9..06126dd0 100644 --- a/cycode/cli/apps/report/sbom/sbom_command.py +++ b/cycode/cli/apps/report/sbom/sbom_command.py @@ -8,6 +8,8 @@ from cycode.cli.utils.sentry import add_breadcrumb from cycode.cyclient.report_client import ReportParameters +_OUTPUT_RICH_HELP_PANEL = 'Output options' + def sbom_command( ctx: typer.Context, @@ -27,6 +29,7 @@ def sbom_command( '--output-format', '-o', help='Specify the output file format.', + rich_help_panel=_OUTPUT_RICH_HELP_PANEL, ), ] = SbomOutputFormatOption.JSON, output_file: Annotated[ @@ -36,6 +39,7 @@ def sbom_command( show_default='Autogenerated filename saved to the current directory', dir_okay=False, writable=True, + rich_help_panel=_OUTPUT_RICH_HELP_PANEL, ), ] = None, include_vulnerabilities: Annotated[ diff --git a/cycode/cli/apps/report/sbom/sbom_report_file.py b/cycode/cli/apps/report/sbom/sbom_report_file.py index 4d58f89f..f3178b44 100644 --- a/cycode/cli/apps/report/sbom/sbom_report_file.py +++ b/cycode/cli/apps/report/sbom/sbom_report_file.py @@ -3,7 +3,9 @@ import re from typing import Optional -import click +import typer + +from cycode.cli.console import console class SbomReportFile: @@ -21,14 +23,14 @@ def is_exists(self) -> bool: return self._file_path.exists() def _prompt_overwrite(self) -> bool: - return click.confirm(f'File {self._file_path} already exists. Save with a different filename?', default=True) + return typer.confirm(f'File {self._file_path} already exists. Save with a different filename?', default=True) def _write(self, content: str) -> None: with open(self._file_path, 'w', encoding='UTF-8') as f: f.write(content) def _notify_about_saved_file(self) -> None: - click.echo(f'Report saved to {self._file_path}') + console.print(f'Report saved to {self._file_path}') def _find_and_set_unique_filename(self) -> None: attempt_no = 0 diff --git a/cycode/cli/apps/scan/__init__.py b/cycode/cli/apps/scan/__init__.py index e8602091..07c15978 100644 --- a/cycode/cli/apps/scan/__init__.py +++ b/cycode/cli/apps/scan/__init__.py @@ -16,18 +16,22 @@ app.command(name='path', short_help='Scan the files in the paths provided in the command.')(path_command) app.command(name='repository', short_help='Scan the Git repository included files.')(repository_command) -app.command(name='commit_history', short_help='Scan all the commits history in this git repository.')( +app.command(name='commit-history', short_help='Scan all the commits history in this git repository.')( commit_history_command ) - app.command( - name='pre_commit', + name='pre-commit', short_help='Use this command in pre-commit hook to scan any content that was not committed yet.', rich_help_panel='Automation commands', )(pre_commit_command) app.command( - name='pre_receive', + name='pre-receive', short_help='Use this command in pre-receive hook ' 'to scan commits on the server side before pushing them to the repository.', rich_help_panel='Automation commands', )(pre_receive_command) + +# backward compatibility +app.command(hidden=True, name='commit_history')(commit_history_command) +app.command(hidden=True, name='pre_commit')(pre_commit_command) +app.command(hidden=True, name='pre_receive')(pre_receive_command) diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index a3b1201e..a44d9c15 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -12,6 +12,7 @@ from cycode.cli import consts from cycode.cli.cli_types import SeverityOption from cycode.cli.config import configuration_manager +from cycode.cli.console import console from cycode.cli.exceptions import custom_exceptions from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception from cycode.cli.files_collector.excluder import exclude_irrelevant_documents_to_scan @@ -83,7 +84,7 @@ def scan_sca_commit_range(ctx: typer.Context, path: str, commit_range: str) -> N scan_commit_range_documents(ctx, from_commit_documents, to_commit_documents, scan_parameters=scan_parameters) -def scan_disk_files(ctx: click.Context, paths: Tuple[str]) -> None: +def scan_disk_files(ctx: typer.Context, paths: Tuple[str]) -> None: scan_type = ctx.obj['scan_type'] progress_bar = ctx.obj['progress_bar'] @@ -642,7 +643,7 @@ def parse_pre_receive_input() -> str: return pre_receive_input.splitlines()[0] -def _get_default_scan_parameters(ctx: click.Context) -> dict: +def _get_default_scan_parameters(ctx: typer.Context) -> dict: return { 'monitor': ctx.obj.get('monitor'), 'report': ctx.obj.get('report'), @@ -916,7 +917,7 @@ def _try_get_report_url_if_needed( logger.debug('Failed to get report URL', exc_info=e) -def _set_aggregation_report_url(ctx: click.Context, aggregation_report_url: Optional[str] = None) -> None: +def _set_aggregation_report_url(ctx: typer.Context, aggregation_report_url: Optional[str] = None) -> None: ctx.obj['aggregation_report_url'] = aggregation_report_url @@ -1007,7 +1008,7 @@ def _normalize_file_path(path: str) -> str: def perform_post_pre_receive_scan_actions(ctx: typer.Context) -> None: if scan_utils.is_scan_failed(ctx): - click.echo(consts.PRE_RECEIVE_REMEDIATION_MESSAGE) + console.print(consts.PRE_RECEIVE_REMEDIATION_MESSAGE) def enable_verbose_mode(ctx: typer.Context) -> None: diff --git a/cycode/cli/apps/scan/repository/repository_command.py b/cycode/cli/apps/scan/repository/repository_command.py index 7d6b421a..a99cc2d1 100644 --- a/cycode/cli/apps/scan/repository/repository_command.py +++ b/cycode/cli/apps/scan/repository/repository_command.py @@ -63,6 +63,6 @@ def repository_command( perform_pre_scan_documents_actions(ctx, scan_type, documents_to_scan) logger.debug('Found all relevant files for scanning %s', {'path': path, 'branch': branch}) - scan_documents(ctx, documents_to_scan, get_scan_parameters(ctx, (path,))) + scan_documents(ctx, documents_to_scan, get_scan_parameters(ctx, (str(path),))) except Exception as e: handle_scan_exception(ctx, e) diff --git a/cycode/cli/apps/scan/scan_ci/ci_integrations.py b/cycode/cli/apps/scan/scan_ci/ci_integrations.py index f2869b2f..3cb617a9 100644 --- a/cycode/cli/apps/scan/scan_ci/ci_integrations.py +++ b/cycode/cli/apps/scan/scan_ci/ci_integrations.py @@ -2,6 +2,8 @@ import click +from cycode.cli.console import console + def github_action_range() -> str: before_sha = os.getenv('BEFORE_SHA') @@ -11,7 +13,7 @@ def github_action_range() -> str: head_sha = os.getenv('GITHUB_SHA') ref = os.getenv('GITHUB_REF') - click.echo(f'{before_sha}, {push_base_sha}, {pr_base_sha}, {default_branch}, {head_sha}, {ref}') + console.print(f'{before_sha}, {push_base_sha}, {pr_base_sha}, {default_branch}, {head_sha}, {ref}') if before_sha and before_sha != NO_COMMITS: return f'{before_sha}...' @@ -26,7 +28,7 @@ def circleci_range() -> str: before_sha = os.getenv('BEFORE_SHA') current_sha = os.getenv('CURRENT_SHA') commit_range = f'{before_sha}...{current_sha}' - click.echo(f'commit range: {commit_range}') + console.print(f'commit range: {commit_range}') if not commit_range.startswith('...'): return commit_range diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 5b9c43c6..6b776689 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -12,6 +12,9 @@ from cycode.cli.utils.get_api_client import get_scan_cycode_client from cycode.cli.utils.sentry import add_breadcrumb +_AUTH_RICH_HELP_PANEL = 'Authentication options' +_SCA_RICH_HELP_PANEL = 'SCA options' + def scan_command( ctx: typer.Context, @@ -28,14 +31,14 @@ def scan_command( Optional[str], typer.Option( help='Specify a Cycode client secret for this specific scan execution.', - rich_help_panel='Authentication options', + rich_help_panel=_AUTH_RICH_HELP_PANEL, ), ] = None, client_id: Annotated[ Optional[str], typer.Option( help='Specify a Cycode client ID for this specific scan execution.', - rich_help_panel='Authentication options', + rich_help_panel=_AUTH_RICH_HELP_PANEL, ), ] = None, show_secret: Annotated[bool, typer.Option('--show-secret', help='Show Secrets in plain text.')] = False, @@ -65,7 +68,7 @@ def scan_command( List[ScaScanTypeOption], typer.Option( help='Specify the type of SCA scan you wish to execute.', - rich_help_panel='SCA options', + rich_help_panel=_SCA_RICH_HELP_PANEL, ), ] = (ScaScanTypeOption.PACKAGE_VULNERABILITIES, ScaScanTypeOption.LICENSE_COMPLIANCE), monitor: Annotated[ @@ -73,7 +76,7 @@ def scan_command( typer.Option( '--monitor', help='When specified, the scan results are recorded in the Discovery module.', - rich_help_panel='SCA options', + rich_help_panel=_SCA_RICH_HELP_PANEL, ), ] = False, no_restore: Annotated[ @@ -82,7 +85,7 @@ def scan_command( '--no-restore', help='When specified, Cycode will not run restore command. ' 'Will scan direct dependencies [bold]only[/bold]!', - rich_help_panel='SCA options', + rich_help_panel=_SCA_RICH_HELP_PANEL, ), ] = False, gradle_all_sub_projects: Annotated[ @@ -91,7 +94,7 @@ def scan_command( '--gradle-all-sub-projects', help='When specified, Cycode will run gradle restore command for all sub projects. ' 'Should run from root project directory [bold]only[/bold]!', - rich_help_panel='SCA options', + rich_help_panel=_SCA_RICH_HELP_PANEL, ), ] = False, ) -> None: diff --git a/cycode/cli/apps/status/status_command.py b/cycode/cli/apps/status/status_command.py index edffd24c..28f8cfba 100644 --- a/cycode/cli/apps/status/status_command.py +++ b/cycode/cli/apps/status/status_command.py @@ -2,14 +2,14 @@ from cycode.cli.apps.status.get_cli_status import get_cli_status from cycode.cli.cli_types import OutputTypeOption +from cycode.cli.console import console def status_command(ctx: typer.Context) -> None: output = ctx.obj['output'] cli_status = get_cli_status() - message = cli_status.as_text() if output == OutputTypeOption.JSON: - message = cli_status.as_json() - - typer.echo(message, color=ctx.color) + console.print_json(cli_status.as_json()) + else: + console.print(cli_status.as_text()) diff --git a/cycode/cli/apps/status/version_command.py b/cycode/cli/apps/status/version_command.py index c36aad4b..272b4a17 100644 --- a/cycode/cli/apps/status/version_command.py +++ b/cycode/cli/apps/status/version_command.py @@ -1,15 +1,10 @@ import typer from cycode.cli.apps.status.status_command import status_command +from cycode.cli.console import console def version_command(ctx: typer.Context) -> None: - typer.echo( - typer.style( - text='The "version" command is deprecated. Please use the "status" command instead.', - fg=typer.colors.YELLOW, - bold=True, - ), - color=ctx.color, - ) + console.print('[yellow][bold]This command is deprecated. Please use the "status" command instead.[/bold][/yellow]') + console.print() # print an empty line status_command(ctx) diff --git a/cycode/cli/cli_types.py b/cycode/cli/cli_types.py index 83451df2..87c6346b 100644 --- a/cycode/cli/cli_types.py +++ b/cycode/cli/cli_types.py @@ -38,6 +38,15 @@ class SeverityOption(str, Enum): HIGH = 'high' CRITICAL = 'critical' + @classmethod + def _missing_(cls, value: str) -> str: + value = value.lower() + for member in cls: + if member.lower() == value: + return member + + return cls.INFO # fallback to INFO if no match is found + @staticmethod def get_member_weight(name: str) -> int: return _SEVERITY_WEIGHTS.get(name.lower(), _SEVERITY_DEFAULT_WEIGHT) @@ -46,6 +55,10 @@ def get_member_weight(name: str) -> int: def get_member_color(name: str) -> str: return _SEVERITY_COLORS.get(name.lower(), _SEVERITY_DEFAULT_COLOR) + def __rich__(self) -> str: + color = self.get_member_color(self.value) + return f'[{color}]{self.value.upper()}[/{color}]' + _SEVERITY_DEFAULT_WEIGHT = -1 _SEVERITY_WEIGHTS = { diff --git a/cycode/cli/console.py b/cycode/cli/console.py new file mode 100644 index 00000000..159f4733 --- /dev/null +++ b/cycode/cli/console.py @@ -0,0 +1,47 @@ +import os +from typing import Optional + +from rich.console import Console + +console_out = Console() +console_err = Console(stderr=True) + +console = console_out # alias + + +def is_dark_console() -> Optional[bool]: + """Detect if the console is dark or light. + + This function checks the environment variables and terminal type to determine if the console is dark or light. + + Used approaches: + 1. Check the `LC_DARK_BG` environment variable. + 2. Check the `COLORFGBG` environment variable for background color. + + And it still could be wrong in some cases. + + TODO(MarshalX): migrate to https://github.com/dalance/termbg when someone will implement it for Python. + """ + dark = None + + dark_bg = os.environ.get('LC_DARK_BG') + if dark_bg is not None: + return dark_bg != '0' + + # If BG color in {0, 1, 2, 3, 4, 5, 6, 8} then dark, else light. + try: + color = os.environ.get('COLORFGBG') + *_, bg = color.split(';') + bg = int(bg) + dark = bool(0 <= bg <= 6 or bg == 8) + except Exception: # noqa: S110 + pass + + return dark + + +_SYNTAX_HIGHLIGHT_DARK_THEME = 'monokai' +_SYNTAX_HIGHLIGHT_LIGHT_THEME = 'default' + +# when we could not detect it, use dark theme as most terminals are dark +_SYNTAX_HIGHLIGHT_THEME = _SYNTAX_HIGHLIGHT_LIGHT_THEME if is_dark_console() is False else _SYNTAX_HIGHLIGHT_DARK_THEME diff --git a/cycode/cli/printers/json_printer.py b/cycode/cli/printers/json_printer.py index c8fbacb3..76b1f7c7 100644 --- a/cycode/cli/printers/json_printer.py +++ b/cycode/cli/printers/json_printer.py @@ -1,8 +1,7 @@ import json from typing import TYPE_CHECKING, Dict, List, Optional -import click - +from cycode.cli.console import console from cycode.cli.models import CliError, CliResult from cycode.cli.printers.printer_base import PrinterBase from cycode.cyclient.models import DetectionSchema @@ -15,12 +14,12 @@ class JsonPrinter(PrinterBase): def print_result(self, result: CliResult) -> None: result = {'result': result.success, 'message': result.message, 'data': result.data} - click.echo(self.get_data_json(result)) + console.print_json(self.get_data_json(result)) def print_error(self, error: CliError) -> None: result = {'error': error.code, 'message': error.message} - click.echo(self.get_data_json(result)) + console.print_json(self.get_data_json(result)) def print_scan_results( self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None @@ -47,13 +46,12 @@ def print_scan_results( # FIXME(MarshalX): we don't care about scan IDs in JSON output due to clumsy JSON root structure inlined_errors = [err._asdict() for err in errors.values()] - click.echo(self._get_json_scan_result(scan_ids, detections_dict, report_urls, inlined_errors)) + console.print_json(self._get_json_scan_result(scan_ids, detections_dict, report_urls, inlined_errors)) def _get_json_scan_result( self, scan_ids: List[str], detections: dict, report_urls: List[str], errors: List[dict] ) -> str: result = { - 'scan_id': 'DEPRECATED', # backward compatibility 'scan_ids': scan_ids, 'detections': detections, 'report_urls': report_urls, @@ -65,4 +63,4 @@ def _get_json_scan_result( @staticmethod def get_data_json(data: dict) -> str: # ensure_ascii is disabled for symbols like "`". Eg: `cycode scan` - return json.dumps(data, indent=4, ensure_ascii=False) + return json.dumps(data, ensure_ascii=False) diff --git a/cycode/cli/printers/printer_base.py b/cycode/cli/printers/printer_base.py index 633a2ccc..c6c5120b 100644 --- a/cycode/cli/printers/printer_base.py +++ b/cycode/cli/printers/printer_base.py @@ -4,6 +4,7 @@ import typer +from cycode.cli.console import console_err from cycode.cli.models import CliError, CliResult from cycode.cyclient.headers import get_correlation_id @@ -11,13 +12,17 @@ from cycode.cli.models import LocalScanResult -from rich.console import Console -from rich.traceback import Traceback +from rich.traceback import Traceback as RichTraceback class PrinterBase(ABC): - RED_COLOR_NAME = 'red' - GREEN_COLOR_NAME = 'green' + NO_DETECTIONS_MESSAGE = ( + '[green]Good job! No issues were found!!! :clapping_hands::clapping_hands::clapping_hands:[/green]' + ) + FAILED_SCAN_MESSAGE = ( + '[red]Unfortunately, Cycode was unable to complete the full scan. ' + 'Please note that not all results may be available:[/red]' + ) def __init__(self, ctx: typer.Context) -> None: self.ctx = ctx @@ -36,15 +41,19 @@ def print_result(self, result: CliResult) -> None: def print_error(self, error: CliError) -> None: pass - def print_exception(self, e: Optional[BaseException] = None) -> None: + @staticmethod + def print_exception(e: Optional[BaseException] = None) -> None: """We are printing it in stderr so, we don't care about supporting JSON and TABLE outputs. Note: Called only when the verbose flag is set. """ - console = Console(stderr=True) - - traceback = Traceback.from_exception(type(e), e, None) if e else Traceback.from_exception(*sys.exc_info()) - console.print(traceback) - - console.print(f'Correlation ID: {get_correlation_id()}', style=self.RED_COLOR_NAME) + rich_traceback = ( + RichTraceback.from_exception(type(e), e, e.__traceback__) + if e + else RichTraceback.from_exception(*sys.exc_info()) + ) + rich_traceback.show_locals = False + console_err.print(rich_traceback) + + console_err.print(f'[red]Correlation ID:[/red] {get_correlation_id()}') diff --git a/cycode/cli/printers/tables/sca_table_printer.py b/cycode/cli/printers/tables/sca_table_printer.py index b77f55ed..f5c38279 100644 --- a/cycode/cli/printers/tables/sca_table_printer.py +++ b/cycode/cli/printers/tables/sca_table_printer.py @@ -1,9 +1,8 @@ from collections import defaultdict from typing import TYPE_CHECKING, Dict, List, Set, Tuple -import typer - from cycode.cli.cli_types import SeverityOption +from cycode.cli.console import console from cycode.cli.consts import LICENSE_COMPLIANCE_POLICY_ID, PACKAGE_VULNERABILITY_POLICY_ID from cycode.cli.models import Detection from cycode.cli.printers.tables.table import Table @@ -137,10 +136,10 @@ def _enrich_table_with_values(policy_id: str, table: Table, detection: Detection elif policy_id == LICENSE_COMPLIANCE_POLICY_ID: severity = detection.severity - if not severity: - severity = 'N/A' - - table.add_cell(SEVERITY_COLUMN, severity, SeverityOption.get_member_color(severity)) + if severity: + table.add_cell(SEVERITY_COLUMN, SeverityOption(severity)) + else: + table.add_cell(SEVERITY_COLUMN, 'N/A') table.add_cell(REPOSITORY_COLUMN, detection_details.get('repository_name')) table.add_file_path_cell(CODE_PROJECT_COLUMN, detection_details.get('file_name')) @@ -179,7 +178,7 @@ def _enrich_table_with_values(policy_id: str, table: Table, detection: Detection @staticmethod def _print_summary_issues(detections_count: int, title: str) -> None: - typer.echo(f'⛔ Found {detections_count} issues of type: {typer.style(title, bold=True)}') + console.print(f':no_entry: Found {detections_count} issues of type: [bold]{title}[/bold]') @staticmethod def _extract_detections_per_policy_id( diff --git a/cycode/cli/printers/tables/table_printer.py b/cycode/cli/printers/tables/table_printer.py index 853e2465..12e2dbf3 100644 --- a/cycode/cli/printers/tables/table_printer.py +++ b/cycode/cli/printers/tables/table_printer.py @@ -117,7 +117,7 @@ def _enrich_table_with_detection_summary_values( if self.scan_type == SECRET_SCAN_TYPE: issue_type = detection.type - table.add_cell(SEVERITY_COLUMN, detection.severity, SeverityOption.get_member_color(detection.severity)) + table.add_cell(SEVERITY_COLUMN, SeverityOption(detection.severity)) table.add_cell(ISSUE_TYPE_COLUMN, issue_type) table.add_file_path_cell(FILE_PATH_COLUMN, document.path) table.add_cell(SECRET_SHA_COLUMN, detection.detection_details.get('sha512', '')) diff --git a/cycode/cli/printers/tables/table_printer_base.py b/cycode/cli/printers/tables/table_printer_base.py index 71b4f399..73ab7f88 100644 --- a/cycode/cli/printers/tables/table_printer_base.py +++ b/cycode/cli/printers/tables/table_printer_base.py @@ -2,8 +2,8 @@ from typing import TYPE_CHECKING, Dict, List, Optional import typer -from rich.console import Console +from cycode.cli.console import console from cycode.cli.models import CliError, CliResult from cycode.cli.printers.printer_base import PrinterBase from cycode.cli.printers.text_printer import TextPrinter @@ -29,7 +29,7 @@ def print_scan_results( self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None ) -> None: if not errors and all(result.issue_detected == 0 for result in local_scan_results): - typer.secho('Good job! No issues were found!!! 👏👏👏', fg=self.GREEN_COLOR_NAME) + console.print(self.NO_DETECTIONS_MESSAGE) return self._print_results(local_scan_results) @@ -37,13 +37,9 @@ def print_scan_results( if not errors: return - typer.secho( - 'Unfortunately, Cycode was unable to complete the full scan. ' - 'Please note that not all results may be available:', - fg='red', - ) + console.print(self.FAILED_SCAN_MESSAGE) for scan_id, error in errors.items(): - typer.echo(f'- {scan_id}: ', nl=False) + console.print(f'- {scan_id}: ', end='') self.print_error(error) def _is_git_repository(self) -> bool: @@ -56,7 +52,7 @@ def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: @staticmethod def _print_table(table: 'Table') -> None: if table.get_rows(): - Console().print(table.get_table()) + console.print(table.get_table()) @staticmethod def _print_report_urls( @@ -67,9 +63,9 @@ def _print_report_urls( if not report_urls and not aggregation_report_url: return if aggregation_report_url: - typer.echo(f'Report URL: {aggregation_report_url}') + console.print(f'Report URL: {aggregation_report_url}') return - typer.echo('Report URLs:') + console.print('Report URLs:') for report_url in report_urls: - typer.echo(f'- {report_url}') + console.print(f'- {report_url}') diff --git a/cycode/cli/printers/text_printer.py b/cycode/cli/printers/text_printer.py index 73eaccc2..75725aac 100644 --- a/cycode/cli/printers/text_printer.py +++ b/cycode/cli/printers/text_printer.py @@ -3,11 +3,11 @@ from typing import TYPE_CHECKING, Dict, List, Optional import typer -from rich.console import Console from rich.markup import escape from rich.syntax import Syntax from cycode.cli.cli_types import SeverityOption +from cycode.cli.console import _SYNTAX_HIGHLIGHT_THEME, console from cycode.cli.consts import COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES, SECRET_SCAN_TYPE from cycode.cli.models import CliError, CliResult, Detection, Document, DocumentDetections from cycode.cli.printers.printer_base import PrinterBase @@ -25,27 +25,27 @@ def __init__(self, ctx: typer.Context) -> None: self.show_secret: bool = ctx.obj.get('show_secret', False) def print_result(self, result: CliResult) -> None: - color = None + color = 'default' if not result.success: - color = self.RED_COLOR_NAME + color = 'red' - typer.secho(result.message, fg=color) + console.print(result.message, style=color) if not result.data: return - typer.secho('\nAdditional data:', fg=color) + console.print('\nAdditional data:', style=color) for name, value in result.data.items(): - typer.secho(f'- {name}: {value}', fg=color) + console.print(f'- {name}: {value}', style=color) def print_error(self, error: CliError) -> None: - typer.secho(error.message, fg=self.RED_COLOR_NAME) + console.print(f'[red]Error: {error.message}[/red]') def print_scan_results( self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None ) -> None: if not errors and all(result.issue_detected == 0 for result in local_scan_results): - typer.secho('Good job! No issues were found!!! 👏👏👏', fg=self.GREEN_COLOR_NAME) + console.print(self.NO_DETECTIONS_MESSAGE) return for local_scan_result in local_scan_results: @@ -58,13 +58,9 @@ def print_scan_results( if not errors: return - typer.secho( - 'Unfortunately, Cycode was unable to complete the full scan. ' - 'Please note that not all results may be available:', - fg='red', - ) + console.print(self.FAILED_SCAN_MESSAGE) for scan_id, error in errors.items(): - typer.echo(f'- {scan_id}: ', nl=False) + console.print(f'- {scan_id}: ', end='') self.print_error(error) def _print_document_detections(self, document_detections: DocumentDetections) -> None: @@ -77,14 +73,11 @@ def _print_document_detections(self, document_detections: DocumentDetections) -> @staticmethod def _print_new_line() -> None: - typer.echo() + console.print() def _print_detection_summary(self, detection: Detection, document_path: str) -> None: - detection_name = detection.type if self.scan_type == SECRET_SCAN_TYPE else detection.message - - detection_severity = detection.severity or 'N/A' - detection_severity_color = SeverityOption.get_member_color(detection_severity) - detection_severity = f'[{detection_severity_color}]{detection_severity.upper()}[/{detection_severity_color}]' + name = detection.type if self.scan_type == SECRET_SCAN_TYPE else detection.message + severity = SeverityOption(detection.severity) if detection.severity else 'N/A' escaped_document_path = escape(urllib.parse.quote(document_path)) clickable_document_path = f'[link file://{escaped_document_path}]{document_path}' @@ -95,14 +88,14 @@ def _print_detection_summary(self, detection: Detection, document_path: str) -> company_guidelines = detection.detection_details.get('custom_remediation_guidelines') company_guidelines_message = f'\nCompany Guideline: {company_guidelines}' if company_guidelines else '' - Console().print( - f':no_entry: ' - f'Found {detection_severity} issue of type: [bright_red][bold]{detection_name}[/bold][/bright_red] ' + console.print( + ':no_entry: Found', + severity, + f'issue of type: [bright_red][bold]{name}[/bold][/bright_red] ' f'in file: {clickable_document_path} ' f'{detection_commit_id_message}' f'{company_guidelines_message}' f' :no_entry:', - highlight=True, ) def _print_detection_code_segment( @@ -120,12 +113,12 @@ def _print_report_urls(report_urls: List[str], aggregation_report_url: Optional[ if not report_urls and not aggregation_report_url: return if aggregation_report_url: - typer.echo(f'Report URL: {aggregation_report_url}') + console.print(f'Report URL: {aggregation_report_url}') return - typer.echo('Report URLs:') + console.print('Report URLs:') for report_url in report_urls: - typer.echo(f'- {report_url}') + console.print(f'- {report_url}') @staticmethod def _get_code_segment_start_line(detection_line: int, lines_to_display: int) -> int: @@ -163,8 +156,9 @@ def _print_detection_from_file(self, detection: Detection, document: Document, l code_lines_to_render.append(line_content) code_to_render = '\n'.join(code_lines_to_render) - Console().print( + console.print( Syntax( + theme=_SYNTAX_HIGHLIGHT_THEME, code=code_to_render, lexer=Syntax.guess_lexer(document.path, code=code_to_render), line_numbers=True, @@ -189,9 +183,10 @@ def _print_detection_from_git_diff(self, detection: Detection, document: Documen violation = line_content[detection_position_in_line : detection_position_in_line + violation_length] line_content = line_content.replace(violation, obfuscate_text(violation)) - Console().print( + console.print( Syntax( - line_content, + theme=_SYNTAX_HIGHLIGHT_THEME, + code=line_content, lexer='diff', line_numbers=True, start_line=detection_line, diff --git a/cycode/cli/utils/progress_bar.py b/cycode/cli/utils/progress_bar.py index e0fec5aa..054d5cf8 100644 --- a/cycode/cli/utils/progress_bar.py +++ b/cycode/cli/utils/progress_bar.py @@ -4,6 +4,7 @@ from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn, TimeElapsedColumn +from cycode.cli.console import console from cycode.cli.utils.enum_utils import AutoCountEnum from cycode.logger import get_logger @@ -135,7 +136,6 @@ class CompositeProgressBar(BaseProgressBar): def __init__(self, progress_bar_sections: ProgressBarSections) -> None: super().__init__() - self._run = False self._progress_bar_sections = progress_bar_sections self._section_lengths: Dict[ProgressBarSection, int] = {} @@ -145,7 +145,7 @@ def __init__(self, progress_bar_sections: ProgressBarSections) -> None: self._current_section: ProgressBarSectionInfo = _get_initial_section(self._progress_bar_sections) self._current_right_side_label = '' - self._progress_bar = Progress(*_PROGRESS_BAR_COLUMNS) + self._progress_bar = Progress(*_PROGRESS_BAR_COLUMNS, console=console, refresh_per_second=5, transient=True) self._progress_bar_task_id = self._progress_bar.add_task( description=self._current_section.label, total=_PROGRESS_BAR_LENGTH, @@ -158,15 +158,14 @@ def _progress_bar_update(self, advance: int = 0) -> None: advance=advance, description=self._current_section.label, right_side_label=self._current_right_side_label, + refresh=True, ) def start(self) -> None: - if not self._run: - self._progress_bar.start() + self._progress_bar.start() def stop(self) -> None: - if self._run: - self._progress_bar.stop() + self._progress_bar.stop() def set_section_length(self, section: 'ProgressBarSection', length: int = 0) -> None: logger.debug('Calling set_section_length, %s', {'section': str(section), 'length': length}) diff --git a/cycode/cli/utils/shell_executor.py b/cycode/cli/utils/shell_executor.py index a7a537e6..812fee1f 100644 --- a/cycode/cli/utils/shell_executor.py +++ b/cycode/cli/utils/shell_executor.py @@ -2,6 +2,7 @@ from typing import List, Optional, Union import click +import typer from cycode.logger import get_logger @@ -29,7 +30,7 @@ def shell( logger.debug('Error occurred while running shell command', exc_info=e) except subprocess.TimeoutExpired as e: logger.debug('Command timed out', exc_info=e) - raise click.Abort(f'Command "{command}" timed out') from e + raise typer.Abort(f'Command "{command}" timed out') from e except Exception as e: logger.debug('Unhandled exception occurred while running shell command', exc_info=e) raise click.ClickException(f'Unhandled exception: {e}') from e diff --git a/cycode/cli/utils/version_checker.py b/cycode/cli/utils/version_checker.py index 40022cbd..f35e53a2 100644 --- a/cycode/cli/utils/version_checker.py +++ b/cycode/cli/utils/version_checker.py @@ -4,8 +4,7 @@ from pathlib import Path from typing import List, Optional, Tuple -import typer - +from cycode.cli.console import console from cycode.cli.user_settings.configuration_manager import ConfigurationManager from cycode.cli.utils.path_utils import get_file_content from cycode.cyclient.cycode_client_base import CycodeClientBase @@ -181,7 +180,7 @@ def check_for_update(self, current_version: str, use_cache: bool = True) -> Opti latest_parts, latest_is_pre = self._parse_version(latest_version) return _compare_versions(current_parts, latest_parts, current_is_pre, latest_is_pre, latest_version) - def check_and_notify_update(self, current_version: str, use_color: bool = True, use_cache: bool = True) -> None: + def check_and_notify_update(self, current_version: str, use_cache: bool = True) -> None: """Check for updates and display a notification if a new version is available. Performs the version check and displays a formatted message with update instructions @@ -192,7 +191,6 @@ def check_and_notify_update(self, current_version: str, use_color: bool = True, Args: current_version: Current version of the CLI - use_color: If True, use colored output in the terminal use_cache: If True, use the cached timestamp to determine if an update check is needed """ latest_version = self.check_for_update(current_version, use_cache) @@ -200,11 +198,11 @@ def check_and_notify_update(self, current_version: str, use_color: bool = True, if should_update: update_message = ( '\nNew version of cycode available! ' - f"{typer.style(current_version, fg='yellow')} → {typer.style(latest_version, fg='bright_blue')}\n" - f"Changelog: {typer.style(f'{self.GIT_CHANGELOG_URL_PREFIX}{latest_version}', fg='bright_blue')}\n" - f"Run {typer.style('pip install --upgrade cycode', fg='green')} to update\n" + f'[yellow]{current_version}[/yellow] → [bright_blue]{latest_version}[/bright_blue]\n' + f'Changelog: [bright_blue]{self.GIT_CHANGELOG_URL_PREFIX}{latest_version}[/bright_blue]\n' + f'Run [green]pip install --upgrade cycode[/green] to update\n' ) - typer.echo(update_message, color=use_color) + console.print(update_message) version_checker = VersionChecker() diff --git a/cycode/logger.py b/cycode/logger.py index 684d296c..b63c796f 100644 --- a/cycode/logger.py +++ b/cycode/logger.py @@ -4,10 +4,10 @@ import click import typer -from rich.console import Console from rich.logging import RichHandler from cycode.cli import consts +from cycode.cli.console import console_err from cycode.config import get_val_as_string @@ -19,8 +19,7 @@ def _set_io_encodings() -> None: _set_io_encodings() -_ERROR_CONSOLE = Console(stderr=True) -_RICH_LOGGING_HANDLER = RichHandler(console=_ERROR_CONSOLE, rich_tracebacks=True, tracebacks_suppress=[click, typer]) +_RICH_LOGGING_HANDLER = RichHandler(console=console_err, rich_tracebacks=True, tracebacks_suppress=[click, typer]) logging.basicConfig( level=logging.INFO, diff --git a/tests/cli/commands/test_main_command.py b/tests/cli/commands/test_main_command.py index 04bc3e01..d9890268 100644 --- a/tests/cli/commands/test_main_command.py +++ b/tests/cli/commands/test_main_command.py @@ -47,7 +47,7 @@ def test_passing_output_option(output: str, scan_client: 'ScanClient', api_token if except_json: output = json.loads(result.output) - assert 'scan_id' in output + assert 'scan_ids' in output else: assert 'issue of type:' in result.output diff --git a/tests/cli/exceptions/test_handle_scan_errors.py b/tests/cli/exceptions/test_handle_scan_errors.py index c1d34306..a6b2a9ec 100644 --- a/tests/cli/exceptions/test_handle_scan_errors.py +++ b/tests/cli/exceptions/test_handle_scan_errors.py @@ -1,12 +1,13 @@ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import click import pytest import typer -from click import ClickException from requests import Response +from rich.traceback import Traceback from cycode.cli.cli_types import OutputTypeOption +from cycode.cli.console import console_err from cycode.cli.exceptions import custom_exceptions from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception from cycode.cli.utils.git_proxy import git_proxy @@ -50,7 +51,7 @@ def test_handle_exception_unhandled_error(ctx: typer.Context) -> None: def test_handle_exception_click_error(ctx: typer.Context) -> None: - with ctx, pytest.raises(ClickException): + with ctx, pytest.raises(click.ClickException): handle_scan_exception(ctx, click.ClickException('test')) assert ctx.obj.get('did_fail') is True @@ -62,10 +63,14 @@ def test_handle_exception_verbose(monkeypatch: 'MonkeyPatch') -> None: error_text = 'test' - def mock_secho(msg: str, *_, **__) -> None: - assert error_text in msg or 'Correlation ID:' in msg + def mock_console_print(obj: Any, *_, **__) -> None: + if isinstance(obj, str): + assert 'Correlation ID:' in obj + else: + assert isinstance(obj, Traceback) + assert error_text in str(obj.trace) - monkeypatch.setattr(click, 'secho', mock_secho) + monkeypatch.setattr(console_err, 'print', mock_console_print) with pytest.raises(typer.Exit): handle_scan_exception(ctx, ValueError(error_text)) From 0b32c0d4c5c170b462d236ad464af958ecd84377 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 9 Apr 2025 10:46:23 +0200 Subject: [PATCH 156/257] CM-46370 - Add the error message from the server responses to the user space (#294) --- cycode/cli/apps/ignore/ignore_command.py | 4 ++-- cycode/cli/apps/scan/code_scanner.py | 2 +- cycode/cli/apps/scan/scan_command.py | 7 +++---- cycode/cli/apps/status/version_command.py | 2 +- cycode/cli/cli_types.py | 2 +- cycode/cli/exceptions/custom_exceptions.py | 17 +++++++++-------- cycode/cli/exceptions/handle_errors.py | 2 +- cycode/cli/models.py | 4 ++++ cycode/cli/printers/printer_base.py | 6 +++--- cycode/cli/printers/tables/sca_table_printer.py | 2 +- cycode/cli/printers/tables/table.py | 2 +- cycode/cli/printers/text_printer.py | 4 ++-- cycode/cli/utils/version_checker.py | 6 +++--- 13 files changed, 32 insertions(+), 28 deletions(-) diff --git a/cycode/cli/apps/ignore/ignore_command.py b/cycode/cli/apps/ignore/ignore_command.py index cc6ecd25..079a3c2d 100644 --- a/cycode/cli/apps/ignore/ignore_command.py +++ b/cycode/cli/apps/ignore/ignore_command.py @@ -57,7 +57,7 @@ def ignore_command( # noqa: C901 by_package: Annotated[ Optional[str], typer.Option( - help='Ignore scanning a specific package version. Expected pattern: [cyan]name@version[/cyan].', + help='Ignore scanning a specific package version. Expected pattern: [cyan]name@version[/].', show_default=False, rich_help_panel=_SCA_FILTER_BY_RICH_HELP_PANEL, ), @@ -65,7 +65,7 @@ def ignore_command( # noqa: C901 by_cve: Annotated[ Optional[str], typer.Option( - help='Ignore scanning a specific CVE. Expected pattern: [cyan]CVE-YYYY-NNN[/cyan].', + help='Ignore scanning a specific CVE. Expected pattern: [cyan]CVE-YYYY-NNN[/].', show_default=False, rich_help_panel=_SCA_FILTER_BY_RICH_HELP_PANEL, ), diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index a44d9c15..35208d59 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -649,7 +649,7 @@ def _get_default_scan_parameters(ctx: typer.Context) -> dict: 'report': ctx.obj.get('report'), 'package_vulnerabilities': ctx.obj.get('package-vulnerabilities'), 'license_compliance': ctx.obj.get('license-compliance'), - 'command_type': ctx.info_name, + 'command_type': ctx.info_name.replace('-', '_'), # save backward compatibility 'aggregation_id': str(_generate_unique_id()), } diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 6b776689..3ba7699b 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -83,8 +83,7 @@ def scan_command( bool, typer.Option( '--no-restore', - help='When specified, Cycode will not run restore command. ' - 'Will scan direct dependencies [bold]only[/bold]!', + help='When specified, Cycode will not run restore command. ' 'Will scan direct dependencies [b]only[/]!', rich_help_panel=_SCA_RICH_HELP_PANEL, ), ] = False, @@ -93,14 +92,14 @@ def scan_command( typer.Option( '--gradle-all-sub-projects', help='When specified, Cycode will run gradle restore command for all sub projects. ' - 'Should run from root project directory [bold]only[/bold]!', + 'Should run from root project directory [b]only[/]!', rich_help_panel=_SCA_RICH_HELP_PANEL, ), ] = False, ) -> None: """:magnifying_glass_tilted_right: Scan the content for Secrets, IaC, SCA, and SAST violations. You'll need to specify which scan type to perform: - [cyan]path[/cyan]/[cyan]repository[/cyan]/[cyan]commit_history[/cyan].""" + [cyan]path[/]/[cyan]repository[/]/[cyan]commit_history[/].""" add_breadcrumb('scan') ctx.obj['show_secret'] = show_secret diff --git a/cycode/cli/apps/status/version_command.py b/cycode/cli/apps/status/version_command.py index 272b4a17..e5982548 100644 --- a/cycode/cli/apps/status/version_command.py +++ b/cycode/cli/apps/status/version_command.py @@ -5,6 +5,6 @@ def version_command(ctx: typer.Context) -> None: - console.print('[yellow][bold]This command is deprecated. Please use the "status" command instead.[/bold][/yellow]') + console.print('[b yellow]This command is deprecated. Please use the "status" command instead.[/]') console.print() # print an empty line status_command(ctx) diff --git a/cycode/cli/cli_types.py b/cycode/cli/cli_types.py index 87c6346b..67ae2fb2 100644 --- a/cycode/cli/cli_types.py +++ b/cycode/cli/cli_types.py @@ -57,7 +57,7 @@ def get_member_color(name: str) -> str: def __rich__(self) -> str: color = self.get_member_color(self.value) - return f'[{color}]{self.value.upper()}[/{color}]' + return f'[{color}]{self.value.upper()}[/]' _SEVERITY_DEFAULT_WEIGHT = -1 diff --git a/cycode/cli/exceptions/custom_exceptions.py b/cycode/cli/exceptions/custom_exceptions.py index 40abed63..4d692812 100644 --- a/cycode/cli/exceptions/custom_exceptions.py +++ b/cycode/cli/exceptions/custom_exceptions.py @@ -6,6 +6,10 @@ class CycodeError(Exception): """Base class for all custom exceptions""" + def __str__(self) -> str: + class_name = self.__class__.__name__ + return f'{class_name} error occurred.' + class RequestError(CycodeError): ... @@ -27,10 +31,7 @@ def __init__(self, status_code: int, error_message: str, response: Response) -> super().__init__(self.error_message) def __str__(self) -> str: - return ( - f'error occurred during the request. status code: {self.status_code}, error message: ' - f'{self.error_message}' - ) + return f'HTTP error occurred during the request (code {self.status_code}). Message: {self.error_message}' class ScanAsyncError(CycodeError): @@ -39,7 +40,7 @@ def __init__(self, error_message: str) -> None: super().__init__(self.error_message) def __str__(self) -> str: - return f'error occurred during the scan. error message: {self.error_message}' + return f'Async scan error occurred during the scan. Message: {self.error_message}' class ReportAsyncError(CycodeError): @@ -54,7 +55,7 @@ def __init__(self, error_message: str, response: Response) -> None: super().__init__(self.error_message) def __str__(self) -> str: - return 'Http Unauthorized Error' + return f'HTTP unauthorized error occurred during the request. Message: {self.error_message}' class ZipTooLargeError(CycodeError): @@ -72,7 +73,7 @@ def __init__(self, error_message: str) -> None: super().__init__() def __str__(self) -> str: - return f'Something went wrong during the authentication process, error message: {self.error_message}' + return f'Something went wrong during the authentication process. Message: {self.error_message}' class TfplanKeyError(CycodeError): @@ -106,6 +107,6 @@ def __str__(self) -> str: code='ssl_error', message='An SSL error occurred when trying to connect to the Cycode API. ' 'If you use an on-premises installation or a proxy that intercepts SSL traffic ' - 'you should use the CURL_CA_BUNDLE environment variable to specify path to a valid .pem or similar.', + 'you should use the CURL_CA_BUNDLE environment variable to specify path to a valid .pem or similar', ), } diff --git a/cycode/cli/exceptions/handle_errors.py b/cycode/cli/exceptions/handle_errors.py index db102773..b9cb9c80 100644 --- a/cycode/cli/exceptions/handle_errors.py +++ b/cycode/cli/exceptions/handle_errors.py @@ -14,7 +14,7 @@ def handle_errors( ConsolePrinter(ctx).print_exception(err) if type(err) in cli_errors: - error = cli_errors[type(err)] + error = cli_errors[type(err)].enrich(additional_message=str(err)) if error.soft_fail is True: ctx.obj['soft_fail'] = True diff --git a/cycode/cli/models.py b/cycode/cli/models.py index df62583a..14058f0c 100644 --- a/cycode/cli/models.py +++ b/cycode/cli/models.py @@ -37,6 +37,10 @@ class CliError(NamedTuple): message: str soft_fail: bool = False + def enrich(self, additional_message: str) -> 'CliError': + message = f'{self.message} ({additional_message})' + return CliError(self.code, message, self.soft_fail) + CliErrors = Dict[Type[BaseException], CliError] diff --git a/cycode/cli/printers/printer_base.py b/cycode/cli/printers/printer_base.py index c6c5120b..9a86c3d6 100644 --- a/cycode/cli/printers/printer_base.py +++ b/cycode/cli/printers/printer_base.py @@ -17,11 +17,11 @@ class PrinterBase(ABC): NO_DETECTIONS_MESSAGE = ( - '[green]Good job! No issues were found!!! :clapping_hands::clapping_hands::clapping_hands:[/green]' + '[green]Good job! No issues were found!!! :clapping_hands::clapping_hands::clapping_hands:[/]' ) FAILED_SCAN_MESSAGE = ( '[red]Unfortunately, Cycode was unable to complete the full scan. ' - 'Please note that not all results may be available:[/red]' + 'Please note that not all results may be available:[/]' ) def __init__(self, ctx: typer.Context) -> None: @@ -56,4 +56,4 @@ def print_exception(e: Optional[BaseException] = None) -> None: rich_traceback.show_locals = False console_err.print(rich_traceback) - console_err.print(f'[red]Correlation ID:[/red] {get_correlation_id()}') + console_err.print(f'[red]Correlation ID:[/] {get_correlation_id()}') diff --git a/cycode/cli/printers/tables/sca_table_printer.py b/cycode/cli/printers/tables/sca_table_printer.py index f5c38279..1ba1c3a3 100644 --- a/cycode/cli/printers/tables/sca_table_printer.py +++ b/cycode/cli/printers/tables/sca_table_printer.py @@ -178,7 +178,7 @@ def _enrich_table_with_values(policy_id: str, table: Table, detection: Detection @staticmethod def _print_summary_issues(detections_count: int, title: str) -> None: - console.print(f':no_entry: Found {detections_count} issues of type: [bold]{title}[/bold]') + console.print(f':no_entry: Found {detections_count} issues of type: [b]{title}[/]') @staticmethod def _extract_detections_per_policy_id( diff --git a/cycode/cli/printers/tables/table.py b/cycode/cli/printers/tables/table.py index 23022b2d..b89df4af 100644 --- a/cycode/cli/printers/tables/table.py +++ b/cycode/cli/printers/tables/table.py @@ -28,7 +28,7 @@ def _add_cell_no_error(self, column: 'ColumnInfo', value: str) -> None: def add_cell(self, column: 'ColumnInfo', value: str, color: Optional[str] = None) -> None: if color: - value = f'[{color}]{value}[/{color}]' + value = f'[{color}]{value}[/]' self._add_cell_no_error(column, value) diff --git a/cycode/cli/printers/text_printer.py b/cycode/cli/printers/text_printer.py index 75725aac..7f1db10f 100644 --- a/cycode/cli/printers/text_printer.py +++ b/cycode/cli/printers/text_printer.py @@ -39,7 +39,7 @@ def print_result(self, result: CliResult) -> None: console.print(f'- {name}: {value}', style=color) def print_error(self, error: CliError) -> None: - console.print(f'[red]Error: {error.message}[/red]') + console.print(f'[red]Error: {error.message}[/]', highlight=False) def print_scan_results( self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None @@ -91,7 +91,7 @@ def _print_detection_summary(self, detection: Detection, document_path: str) -> console.print( ':no_entry: Found', severity, - f'issue of type: [bright_red][bold]{name}[/bold][/bright_red] ' + f'issue of type: [b bright_red]{name}[/] ' f'in file: {clickable_document_path} ' f'{detection_commit_id_message}' f'{company_guidelines_message}' diff --git a/cycode/cli/utils/version_checker.py b/cycode/cli/utils/version_checker.py index f35e53a2..035b3595 100644 --- a/cycode/cli/utils/version_checker.py +++ b/cycode/cli/utils/version_checker.py @@ -198,9 +198,9 @@ def check_and_notify_update(self, current_version: str, use_cache: bool = True) if should_update: update_message = ( '\nNew version of cycode available! ' - f'[yellow]{current_version}[/yellow] → [bright_blue]{latest_version}[/bright_blue]\n' - f'Changelog: [bright_blue]{self.GIT_CHANGELOG_URL_PREFIX}{latest_version}[/bright_blue]\n' - f'Run [green]pip install --upgrade cycode[/green] to update\n' + f'[yellow]{current_version}[/] → [bright_blue]{latest_version}[/]\n' + f'Changelog: [bright_blue]{self.GIT_CHANGELOG_URL_PREFIX}{latest_version}[/]\n' + f'Run [green]pip install --upgrade cycode[/] to update\n' ) console.print(update_message) From dad785913731fd3f523805c8ec08272f8a1dbe6b Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 15 Apr 2025 14:22:11 +0200 Subject: [PATCH 157/257] CM-46732 - Add rich output; improve text output (#295) --- cycode/cli/app.py | 2 +- cycode/cli/apps/status/version_command.py | 2 +- cycode/cli/cli_types.py | 14 ++ cycode/cli/console.py | 26 ++- cycode/cli/consts.py | 16 +- cycode/cli/printers/console_printer.py | 7 + cycode/cli/printers/rich_printer.py | 141 +++++++++++++++ .../cli/printers/tables/sca_table_printer.py | 53 +----- cycode/cli/printers/tables/table_printer.py | 54 +----- cycode/cli/printers/text_printer.py | 168 +++++------------- cycode/cli/printers/utils/__init__.py | 0 .../cli/printers/utils/code_snippet_syntax.py | 113 ++++++++++++ cycode/cli/printers/utils/detection_data.py | 16 ++ .../utils/detection_ordering/__init__.py | 0 .../detection_ordering/common_ordering.py | 66 +++++++ .../utils/detection_ordering/sca_ordering.py | 58 ++++++ cycode/cli/printers/utils/rich_helpers.py | 37 ++++ tests/cli/commands/test_main_command.py | 2 +- 18 files changed, 540 insertions(+), 235 deletions(-) create mode 100644 cycode/cli/printers/rich_printer.py create mode 100644 cycode/cli/printers/utils/__init__.py create mode 100644 cycode/cli/printers/utils/code_snippet_syntax.py create mode 100644 cycode/cli/printers/utils/detection_data.py create mode 100644 cycode/cli/printers/utils/detection_ordering/__init__.py create mode 100644 cycode/cli/printers/utils/detection_ordering/common_ordering.py create mode 100644 cycode/cli/printers/utils/detection_ordering/sca_ordering.py create mode 100644 cycode/cli/printers/utils/rich_helpers.py diff --git a/cycode/cli/app.py b/cycode/cli/app.py index 80742bab..aed0e172 100644 --- a/cycode/cli/app.py +++ b/cycode/cli/app.py @@ -59,7 +59,7 @@ def app_callback( ] = False, output: Annotated[ OutputTypeOption, typer.Option('--output', '-o', case_sensitive=False, help='Specify the output type.') - ] = OutputTypeOption.TEXT, + ] = OutputTypeOption.RICH, user_agent: Annotated[ Optional[str], typer.Option(hidden=True, help='Characteristic JSON object that lets servers identify the application.'), diff --git a/cycode/cli/apps/status/version_command.py b/cycode/cli/apps/status/version_command.py index e5982548..ef117fc7 100644 --- a/cycode/cli/apps/status/version_command.py +++ b/cycode/cli/apps/status/version_command.py @@ -6,5 +6,5 @@ def version_command(ctx: typer.Context) -> None: console.print('[b yellow]This command is deprecated. Please use the "status" command instead.[/]') - console.print() # print an empty line + console.line() status_command(ctx) diff --git a/cycode/cli/cli_types.py b/cycode/cli/cli_types.py index 67ae2fb2..2a576ace 100644 --- a/cycode/cli/cli_types.py +++ b/cycode/cli/cli_types.py @@ -4,6 +4,7 @@ class OutputTypeOption(str, Enum): + RICH = 'rich' TEXT = 'text' JSON = 'json' TABLE = 'table' @@ -55,6 +56,10 @@ def get_member_weight(name: str) -> int: def get_member_color(name: str) -> str: return _SEVERITY_COLORS.get(name.lower(), _SEVERITY_DEFAULT_COLOR) + @staticmethod + def get_member_emoji(name: str) -> str: + return _SEVERITY_EMOJIS.get(name.lower(), _SEVERITY_DEFAULT_EMOJI) + def __rich__(self) -> str: color = self.get_member_color(self.value) return f'[{color}]{self.value.upper()}[/]' @@ -77,3 +82,12 @@ def __rich__(self) -> str: SeverityOption.HIGH.value: 'red1', SeverityOption.CRITICAL.value: 'red3', } + +_SEVERITY_DEFAULT_EMOJI = ':white_circle:' +_SEVERITY_EMOJIS = { + SeverityOption.INFO.value: ':blue_circle:', + SeverityOption.LOW.value: ':yellow_circle:', + SeverityOption.MEDIUM.value: ':orange_circle:', + SeverityOption.HIGH.value: ':heavy_large_circle:', + SeverityOption.CRITICAL.value: ':red_circle:', +} diff --git a/cycode/cli/console.py b/cycode/cli/console.py index 159f4733..5d78fc36 100644 --- a/cycode/cli/console.py +++ b/cycode/cli/console.py @@ -1,7 +1,12 @@ import os -from typing import Optional +from typing import TYPE_CHECKING, Optional -from rich.console import Console +from rich.console import Console, RenderResult +from rich.markdown import Heading, Markdown +from rich.text import Text + +if TYPE_CHECKING: + from rich.console import ConsoleOptions console_out = Console() console_err = Console(stderr=True) @@ -45,3 +50,20 @@ def is_dark_console() -> Optional[bool]: # when we could not detect it, use dark theme as most terminals are dark _SYNTAX_HIGHLIGHT_THEME = _SYNTAX_HIGHLIGHT_LIGHT_THEME if is_dark_console() is False else _SYNTAX_HIGHLIGHT_DARK_THEME + + +class CycodeHeading(Heading): + """Custom Rich Heading for Markdown. + + Changes: + - remove justify to 'center' + - remove the box for h1 + """ + + def __rich_console__(self, console: 'Console', options: 'ConsoleOptions') -> RenderResult: + if self.tag == 'h2': + yield Text('') + yield self.text + + +Markdown.elements['heading_open'] = CycodeHeading diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 60953143..9d7a619d 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -2,9 +2,12 @@ APP_NAME = 'CycodeCLI' CLI_CONTEXT_SETTINGS = {'terminal_width': 10**9, 'max_content_width': 10**9, 'help_option_names': ['-h', '--help']} -PRE_COMMIT_COMMAND_SCAN_TYPE = 'pre_commit' -PRE_RECEIVE_COMMAND_SCAN_TYPE = 'pre_receive' -COMMIT_HISTORY_COMMAND_SCAN_TYPE = 'commit_history' +PRE_COMMIT_COMMAND_SCAN_TYPE = 'pre-commit' +PRE_COMMIT_COMMAND_SCAN_TYPE_OLD = 'pre_commit' +PRE_RECEIVE_COMMAND_SCAN_TYPE = 'pre-receive' +PRE_RECEIVE_COMMAND_SCAN_TYPE_OLD = 'pre_receive' +COMMIT_HISTORY_COMMAND_SCAN_TYPE = 'commit-history' +COMMIT_HISTORY_COMMAND_SCAN_TYPE_OLD = 'commit_history' SECRET_SCAN_TYPE = 'secret' # noqa: S105 IAC_SCAN_TYPE = 'iac' @@ -105,7 +108,12 @@ COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES = [SECRET_SCAN_TYPE, SCA_SCAN_TYPE] -COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES = [PRE_RECEIVE_COMMAND_SCAN_TYPE, COMMIT_HISTORY_COMMAND_SCAN_TYPE] +COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES = [ + PRE_RECEIVE_COMMAND_SCAN_TYPE, + PRE_RECEIVE_COMMAND_SCAN_TYPE_OLD, + COMMIT_HISTORY_COMMAND_SCAN_TYPE, + COMMIT_HISTORY_COMMAND_SCAN_TYPE_OLD, +] DEFAULT_CYCODE_DOMAIN = 'cycode.com' DEFAULT_CYCODE_API_URL = f'https://api.{DEFAULT_CYCODE_DOMAIN}' diff --git a/cycode/cli/printers/console_printer.py b/cycode/cli/printers/console_printer.py index 64efa9d5..5ad5dac2 100644 --- a/cycode/cli/printers/console_printer.py +++ b/cycode/cli/printers/console_printer.py @@ -5,6 +5,7 @@ from cycode.cli.exceptions.custom_exceptions import CycodeError from cycode.cli.models import CliError, CliResult from cycode.cli.printers.json_printer import JsonPrinter +from cycode.cli.printers.rich_printer import RichPrinter from cycode.cli.printers.tables.sca_table_printer import ScaTablePrinter from cycode.cli.printers.tables.table_printer import TablePrinter from cycode.cli.printers.text_printer import TextPrinter @@ -16,12 +17,14 @@ class ConsolePrinter: _AVAILABLE_PRINTERS: ClassVar[Dict[str, Type['PrinterBase']]] = { + 'rich': RichPrinter, 'text': TextPrinter, 'json': JsonPrinter, 'table': TablePrinter, # overrides 'table_sca': ScaTablePrinter, 'text_sca': ScaTablePrinter, + 'rich_sca': ScaTablePrinter, } def __init__(self, ctx: typer.Context) -> None: @@ -74,3 +77,7 @@ def is_table_printer(self) -> bool: @property def is_text_printer(self) -> bool: return self._printer_class == TextPrinter + + @property + def is_rich_printer(self) -> bool: + return self._printer_class == RichPrinter diff --git a/cycode/cli/printers/rich_printer.py b/cycode/cli/printers/rich_printer.py new file mode 100644 index 00000000..61cb14ef --- /dev/null +++ b/cycode/cli/printers/rich_printer.py @@ -0,0 +1,141 @@ +from pathlib import Path +from typing import TYPE_CHECKING, Dict, List, Optional + +from rich.console import Group +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +from cycode.cli import consts +from cycode.cli.cli_types import SeverityOption +from cycode.cli.console import console +from cycode.cli.printers.text_printer import TextPrinter +from cycode.cli.printers.utils.code_snippet_syntax import get_code_snippet_syntax +from cycode.cli.printers.utils.detection_data import get_detection_title +from cycode.cli.printers.utils.detection_ordering.common_ordering import sort_and_group_detections_from_scan_result +from cycode.cli.printers.utils.rich_helpers import get_columns_in_1_to_3_ratio, get_markdown_panel, get_panel + +if TYPE_CHECKING: + from cycode.cli.models import CliError, Detection, Document, LocalScanResult + + +class RichPrinter(TextPrinter): + def print_scan_results( + self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None + ) -> None: + if not errors and all(result.issue_detected == 0 for result in local_scan_results): + console.print(self.NO_DETECTIONS_MESSAGE) + return + + current_file = None + detections, _ = sort_and_group_detections_from_scan_result(local_scan_results) + detections_count = len(detections) + for detection_number, (detection, document) in enumerate(detections, start=1): + if current_file != document.path: + current_file = document.path + self._print_file_header(current_file) + + self._print_violation_card( + document, + detection, + detection_number, + detections_count, + ) + + self.print_report_urls_and_errors(local_scan_results, errors) + + @staticmethod + def _print_file_header(file_path: str) -> None: + clickable_path = f'[link=file://{file_path}]{file_path}[/link]' + file_header = Panel( + Text.from_markup(f'[b purple3]:file_folder: File: {clickable_path}[/]', justify='center'), + border_style='dim', + ) + console.print(file_header) + + def _get_details_table(self, detection: 'Detection') -> Table: + details_table = Table(show_header=False, box=None, padding=(0, 1)) + + details_table.add_column('Key', style='dim') + details_table.add_column('Value', style='', overflow='fold') + + severity = detection.severity if detection.severity else 'N/A' + severity_icon = SeverityOption.get_member_emoji(severity.lower()) + details_table.add_row('Severity', f'{severity_icon} {SeverityOption(severity).__rich__()}') + + detection_details = detection.detection_details + path = Path(detection_details.get('file_name', '')) + details_table.add_row('In file', path.name) # it is name already except for IaC :) + + # we do not allow using rich output with SCA; SCA designed to be used with table output + if self.scan_type == consts.IAC_SCAN_TYPE: + details_table.add_row('IaC Provider', detection_details.get('infra_provider')) + elif self.scan_type == consts.SECRET_SCAN_TYPE: + details_table.add_row('Secret SHA', detection_details.get('sha512')) + elif self.scan_type == consts.SAST_SCAN_TYPE: + details_table.add_row('Subcategory', detection_details.get('category')) + details_table.add_row('Language', ', '.join(detection_details.get('languages', []))) + + engine_id_to_display_name = { + '5db84696-88dc-11ec-a8a3-0242ac120002': 'Semgrep OSS (Orchestrated by Cycode)', + '560a0abd-d7da-4e6d-a3f1-0ed74895295c': 'Bearer (Powered by Cycode)', + } + engine_id = detection.detection_details.get('external_scanner_id') + details_table.add_row('Security Tool', engine_id_to_display_name.get(engine_id, 'N/A')) + + details_table.add_row('Rule ID', detection.detection_rule_id) + + return details_table + + def _print_violation_card( + self, document: 'Document', detection: 'Detection', detection_number: int, detections_count: int + ) -> None: + details_table = self._get_details_table(detection) + details_panel = get_panel( + details_table, + title=':mag: Details', + ) + + code_snippet_panel = get_panel( + get_code_snippet_syntax( + self.scan_type, + self.command_scan_type, + detection, + document, + obfuscate=not self.show_secret, + ), + title=':computer: Code Snippet', + ) + + guidelines_panel = None + guidelines = detection.detection_details.get('remediation_guidelines') + if guidelines: + guidelines_panel = get_markdown_panel( + guidelines, + title=':clipboard: Cycode Guidelines', + ) + + custom_guidelines_panel = None + custom_guidelines = detection.detection_details.get('custom_remediation_guidelines') + if custom_guidelines: + custom_guidelines_panel = get_markdown_panel( + custom_guidelines, + title=':office: Company Guidelines', + ) + + navigation = Text(f'Violation {detection_number} of {detections_count}', style='dim', justify='right') + + renderables = [navigation, get_columns_in_1_to_3_ratio(details_panel, code_snippet_panel)] + if guidelines_panel: + renderables.append(guidelines_panel) + if custom_guidelines_panel: + renderables.append(custom_guidelines_panel) + + violation_card_panel = Panel( + Group(*renderables), + title=get_detection_title(self.scan_type, detection), + border_style=SeverityOption.get_member_color(detection.severity), + title_align='center', + ) + + console.print(violation_card_panel) diff --git a/cycode/cli/printers/tables/sca_table_printer.py b/cycode/cli/printers/tables/sca_table_printer.py index 1ba1c3a3..70965be2 100644 --- a/cycode/cli/printers/tables/sca_table_printer.py +++ b/cycode/cli/printers/tables/sca_table_printer.py @@ -1,5 +1,5 @@ from collections import defaultdict -from typing import TYPE_CHECKING, Dict, List, Set, Tuple +from typing import TYPE_CHECKING, Dict, List from cycode.cli.cli_types import SeverityOption from cycode.cli.console import console @@ -8,6 +8,7 @@ from cycode.cli.printers.tables.table import Table from cycode.cli.printers.tables.table_models import ColumnInfoBuilder from cycode.cli.printers.tables.table_printer_base import TablePrinterBase +from cycode.cli.printers.utils.detection_ordering.sca_ordering import sort_and_group_detections from cycode.cli.utils.string_utils import shortcut_dependency_paths if TYPE_CHECKING: @@ -36,7 +37,7 @@ def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: for policy_id, detections in detections_per_policy_id.items(): table = self._get_table(policy_id) - resulting_detections, group_separator_indexes = self._sort_and_group_detections(detections) + resulting_detections, group_separator_indexes = sort_and_group_detections(detections) for detection in resulting_detections: self._enrich_table_with_values(policy_id, table, detection) @@ -56,54 +57,6 @@ def _get_title(policy_id: str) -> str: return 'Unknown' - @staticmethod - def __group_by(detections: List[Detection], details_field_name: str) -> Dict[str, List[Detection]]: - grouped = defaultdict(list) - for detection in detections: - grouped[detection.detection_details.get(details_field_name)].append(detection) - return grouped - - @staticmethod - def __severity_sort_key(detection: Detection) -> int: - severity = detection.detection_details.get('advisory_severity', 'unknown') - return SeverityOption.get_member_weight(severity) - - def _sort_detections_by_severity(self, detections: List[Detection]) -> List[Detection]: - return sorted(detections, key=self.__severity_sort_key, reverse=True) - - @staticmethod - def __package_sort_key(detection: Detection) -> int: - return detection.detection_details.get('package_name') - - def _sort_detections_by_package(self, detections: List[Detection]) -> List[Detection]: - return sorted(detections, key=self.__package_sort_key) - - def _sort_and_group_detections(self, detections: List[Detection]) -> Tuple[List[Detection], Set[int]]: - """Sort detections by severity and group by repository, code project and package name. - - Note: - Code Project is path to the manifest file. - - Grouping by code projects also groups by ecosystem. - Because manifest files are unique per ecosystem. - """ - resulting_detections = [] - group_separator_indexes = set() - - # we sort detections by package name to make persist output order - sorted_detections = self._sort_detections_by_package(detections) - - grouped_by_repository = self.__group_by(sorted_detections, 'repository_name') - for repository_group in grouped_by_repository.values(): - grouped_by_code_project = self.__group_by(repository_group, 'file_name') - for code_project_group in grouped_by_code_project.values(): - grouped_by_package = self.__group_by(code_project_group, 'package_name') - for package_group in grouped_by_package.values(): - group_separator_indexes.add(len(resulting_detections) - 1) # indexing starts from 0 - resulting_detections.extend(self._sort_detections_by_severity(package_group)) - - return resulting_detections, group_separator_indexes - def _get_table(self, policy_id: str) -> Table: table = Table() diff --git a/cycode/cli/printers/tables/table_printer.py b/cycode/cli/printers/tables/table_printer.py index 12e2dbf3..e36b1b01 100644 --- a/cycode/cli/printers/tables/table_printer.py +++ b/cycode/cli/printers/tables/table_printer.py @@ -1,5 +1,4 @@ -from collections import defaultdict -from typing import TYPE_CHECKING, List, Set, Tuple +from typing import TYPE_CHECKING, List from cycode.cli.cli_types import SeverityOption from cycode.cli.consts import SECRET_SCAN_TYPE @@ -7,6 +6,7 @@ from cycode.cli.printers.tables.table import Table from cycode.cli.printers.tables.table_models import ColumnInfoBuilder from cycode.cli.printers.tables.table_printer_base import TablePrinterBase +from cycode.cli.printers.utils.detection_ordering.common_ordering import sort_and_group_detections_from_scan_result from cycode.cli.utils.string_utils import get_position_in_line, obfuscate_text if TYPE_CHECKING: @@ -30,14 +30,7 @@ class TablePrinter(TablePrinterBase): def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: table = self._get_table() - detections_with_documents = [] - for local_scan_result in local_scan_results: - for document_detections in local_scan_result.document_detections: - detections_with_documents.extend( - [(detection, document_detections.document) for detection in document_detections.detections] - ) - - detections, group_separator_indexes = self._sort_and_group_detections(detections_with_documents) + detections, group_separator_indexes = sort_and_group_detections_from_scan_result(local_scan_results) for detection, document in detections: self._enrich_table_with_values(table, detection, document) @@ -46,47 +39,6 @@ def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: self._print_table(table) self._print_report_urls(local_scan_results, self.ctx.obj.get('aggregation_report_url')) - @staticmethod - def __severity_sort_key(detection_with_document: Tuple[Detection, Document]) -> int: - detection, _ = detection_with_document - severity = detection.severity if detection.severity else '' - return SeverityOption.get_member_weight(severity) - - def _sort_detections_by_severity( - self, detections_with_documents: List[Tuple[Detection, Document]] - ) -> List[Tuple[Detection, Document]]: - return sorted(detections_with_documents, key=self.__severity_sort_key, reverse=True) - - @staticmethod - def __file_path_sort_key(detection_with_document: Tuple[Detection, Document]) -> str: - _, document = detection_with_document - return document.path - - def _sort_detections_by_file_path( - self, detections_with_documents: List[Tuple[Detection, Document]] - ) -> List[Tuple[Detection, Document]]: - return sorted(detections_with_documents, key=self.__file_path_sort_key) - - def _sort_and_group_detections( - self, detections_with_documents: List[Tuple[Detection, Document]] - ) -> Tuple[List[Tuple[Detection, Document]], Set[int]]: - """Sort detections by severity and group by file name.""" - detections = [] - group_separator_indexes = set() - - # we sort detections by file path to make persist output order - sorted_detections = self._sort_detections_by_file_path(detections_with_documents) - - grouped_by_file_path = defaultdict(list) - for detection, document in sorted_detections: - grouped_by_file_path[document.path].append((detection, document)) - - for file_path_group in grouped_by_file_path.values(): - group_separator_indexes.add(len(detections) - 1) # indexing starts from 0 - detections.extend(self._sort_detections_by_severity(file_path_group)) - - return detections, group_separator_indexes - def _get_table(self) -> Table: table = Table() diff --git a/cycode/cli/printers/text_printer.py b/cycode/cli/printers/text_printer.py index 7f1db10f..b1eeb38e 100644 --- a/cycode/cli/printers/text_printer.py +++ b/cycode/cli/printers/text_printer.py @@ -1,20 +1,19 @@ -import math import urllib.parse from typing import TYPE_CHECKING, Dict, List, Optional import typer from rich.markup import escape -from rich.syntax import Syntax from cycode.cli.cli_types import SeverityOption -from cycode.cli.console import _SYNTAX_HIGHLIGHT_THEME, console -from cycode.cli.consts import COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES, SECRET_SCAN_TYPE -from cycode.cli.models import CliError, CliResult, Detection, Document, DocumentDetections +from cycode.cli.console import console +from cycode.cli.models import CliError, CliResult, Document from cycode.cli.printers.printer_base import PrinterBase -from cycode.cli.utils.string_utils import get_position_in_line, obfuscate_text +from cycode.cli.printers.utils.code_snippet_syntax import get_code_snippet_syntax +from cycode.cli.printers.utils.detection_data import get_detection_title +from cycode.cli.printers.utils.detection_ordering.common_ordering import sort_and_group_detections_from_scan_result if TYPE_CHECKING: - from cycode.cli.models import LocalScanResult + from cycode.cli.models import Detection, LocalScanResult class TextPrinter(PrinterBase): @@ -48,36 +47,26 @@ def print_scan_results( console.print(self.NO_DETECTIONS_MESSAGE) return - for local_scan_result in local_scan_results: - for document_detections in local_scan_result.document_detections: - self._print_document_detections(document_detections) + detections, _ = sort_and_group_detections_from_scan_result(local_scan_results) + for detection, document in detections: + self.__print_document_detection(document, detection) - report_urls = [scan_result.report_url for scan_result in local_scan_results if scan_result.report_url] - - self._print_report_urls(report_urls, self.ctx.obj.get('aggregation_report_url')) - if not errors: - return - - console.print(self.FAILED_SCAN_MESSAGE) - for scan_id, error in errors.items(): - console.print(f'- {scan_id}: ', end='') - self.print_error(error) + self.print_report_urls_and_errors(local_scan_results, errors) - def _print_document_detections(self, document_detections: DocumentDetections) -> None: - document = document_detections.document - for detection in document_detections.detections: - self._print_detection_summary(detection, document.path) - self._print_new_line() - self._print_detection_code_segment(detection, document) - self._print_new_line() + def __print_document_detection(self, document: 'Document', detection: 'Detection') -> None: + self.__print_detection_summary(detection, document.path) + self.__print_detection_code_segment(detection, document) + self._print_new_line() @staticmethod def _print_new_line() -> None: - console.print() + console.line() + + def __print_detection_summary(self, detection: 'Detection', document_path: str) -> None: + title = get_detection_title(self.scan_type, detection) - def _print_detection_summary(self, detection: Detection, document_path: str) -> None: - name = detection.type if self.scan_type == SECRET_SCAN_TYPE else detection.message severity = SeverityOption(detection.severity) if detection.severity else 'N/A' + severity_icon = SeverityOption.get_member_emoji(detection.severity) if detection.severity else '' escaped_document_path = escape(urllib.parse.quote(document_path)) clickable_document_path = f'[link file://{escaped_document_path}]{document_path}' @@ -85,31 +74,39 @@ def _print_detection_summary(self, detection: Detection, document_path: str) -> detection_commit_id = detection.detection_details.get('commit_id') detection_commit_id_message = f'\nCommit SHA: {detection_commit_id}' if detection_commit_id else '' - company_guidelines = detection.detection_details.get('custom_remediation_guidelines') - company_guidelines_message = f'\nCompany Guideline: {company_guidelines}' if company_guidelines else '' - console.print( - ':no_entry: Found', + f'{severity_icon}', severity, - f'issue of type: [b bright_red]{name}[/] ' - f'in file: {clickable_document_path} ' - f'{detection_commit_id_message}' - f'{company_guidelines_message}' - f' :no_entry:', + f'violation: [b bright_red]{title}[/]{detection_commit_id_message}\n{clickable_document_path}:', + ) + + def __print_detection_code_segment(self, detection: 'Detection', document: Document) -> None: + console.print( + get_code_snippet_syntax( + self.scan_type, + self.command_scan_type, + detection, + document, + obfuscate=not self.show_secret, + ) ) - def _print_detection_code_segment( - self, detection: Detection, document: Document, lines_to_display: int = 3 + def print_report_urls_and_errors( + self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None ) -> None: - if self._is_git_diff_based_scan(): - # it will print just one line - self._print_detection_from_git_diff(detection, document) + report_urls = [scan_result.report_url for scan_result in local_scan_results if scan_result.report_url] + + self.print_report_urls(report_urls, self.ctx.obj.get('aggregation_report_url')) + if not errors: return - self._print_detection_from_file(detection, document, lines_to_display) + console.print(self.FAILED_SCAN_MESSAGE) + for scan_id, error in errors.items(): + console.print(f'- {scan_id}: ', end='') + self.print_error(error) @staticmethod - def _print_report_urls(report_urls: List[str], aggregation_report_url: Optional[str] = None) -> None: + def print_report_urls(report_urls: List[str], aggregation_report_url: Optional[str] = None) -> None: if not report_urls and not aggregation_report_url: return if aggregation_report_url: @@ -119,82 +116,3 @@ def _print_report_urls(report_urls: List[str], aggregation_report_url: Optional[ console.print('Report URLs:') for report_url in report_urls: console.print(f'- {report_url}') - - @staticmethod - def _get_code_segment_start_line(detection_line: int, lines_to_display: int) -> int: - start_line = detection_line - math.ceil(lines_to_display / 2) - return 0 if start_line < 0 else start_line - - def _get_detection_line(self, detection: Detection) -> int: - return ( - detection.detection_details.get('line', -1) - if self.scan_type == SECRET_SCAN_TYPE - else detection.detection_details.get('line_in_file', -1) - 1 - ) - - def _print_detection_from_file(self, detection: Detection, document: Document, lines_to_display: int) -> None: - detection_details = detection.detection_details - detection_line = self._get_detection_line(detection) - start_line_index = self._get_code_segment_start_line(detection_line, lines_to_display) - detection_position = get_position_in_line(document.content, detection_details.get('start_position', -1)) - violation_length = detection_details.get('length', -1) - - code_lines_to_render = [] - document_content_lines = document.content.splitlines() - for line_index in range(lines_to_display): - current_line_index = start_line_index + line_index - if current_line_index >= len(document_content_lines): - break - - line_content = document_content_lines[current_line_index] - - line_with_detection = current_line_index == detection_line - if self.scan_type == SECRET_SCAN_TYPE and line_with_detection and not self.show_secret: - violation = line_content[detection_position : detection_position + violation_length] - code_lines_to_render.append(line_content.replace(violation, obfuscate_text(violation))) - else: - code_lines_to_render.append(line_content) - - code_to_render = '\n'.join(code_lines_to_render) - console.print( - Syntax( - theme=_SYNTAX_HIGHLIGHT_THEME, - code=code_to_render, - lexer=Syntax.guess_lexer(document.path, code=code_to_render), - line_numbers=True, - dedent=True, - tab_size=2, - start_line=start_line_index + 1, - highlight_lines={ - detection_line + 1, - }, - ) - ) - - def _print_detection_from_git_diff(self, detection: Detection, document: Document) -> None: - detection_details = detection.detection_details - detection_line = self._get_detection_line(detection) - detection_position = detection_details.get('start_position', -1) - violation_length = detection_details.get('length', -1) - - line_content = document.content.splitlines()[detection_line] - detection_position_in_line = get_position_in_line(document.content, detection_position) - if self.scan_type == SECRET_SCAN_TYPE and not self.show_secret: - violation = line_content[detection_position_in_line : detection_position_in_line + violation_length] - line_content = line_content.replace(violation, obfuscate_text(violation)) - - console.print( - Syntax( - theme=_SYNTAX_HIGHLIGHT_THEME, - code=line_content, - lexer='diff', - line_numbers=True, - start_line=detection_line, - dedent=True, - tab_size=2, - highlight_lines={detection_line + 1}, - ) - ) - - def _is_git_diff_based_scan(self) -> bool: - return self.command_scan_type in COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES and self.scan_type == SECRET_SCAN_TYPE diff --git a/cycode/cli/printers/utils/__init__.py b/cycode/cli/printers/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/printers/utils/code_snippet_syntax.py b/cycode/cli/printers/utils/code_snippet_syntax.py new file mode 100644 index 00000000..c3c9f59b --- /dev/null +++ b/cycode/cli/printers/utils/code_snippet_syntax.py @@ -0,0 +1,113 @@ +import math +from typing import TYPE_CHECKING + +from rich.syntax import Syntax + +from cycode.cli import consts +from cycode.cli.console import _SYNTAX_HIGHLIGHT_THEME +from cycode.cli.utils.string_utils import get_position_in_line, obfuscate_text + +if TYPE_CHECKING: + from cycode.cli.models import Document + from cycode.cyclient.models import Detection + + +def _get_code_segment_start_line(detection_line: int, lines_to_display: int) -> int: + start_line = detection_line - math.ceil(lines_to_display / 2) + return 0 if start_line < 0 else start_line + + +def _get_detection_line(scan_type: str, detection: 'Detection') -> int: + return ( + detection.detection_details.get('line', -1) + if scan_type == consts.SECRET_SCAN_TYPE + else detection.detection_details.get('line_in_file', -1) - 1 + ) + + +def _get_code_snippet_syntax_from_file( + scan_type: str, detection: 'Detection', document: 'Document', lines_to_display: int, obfuscate: bool +) -> Syntax: + detection_details = detection.detection_details + detection_line = _get_detection_line(scan_type, detection) + start_line_index = _get_code_segment_start_line(detection_line, lines_to_display) + detection_position = get_position_in_line(document.content, detection_details.get('start_position', -1)) + violation_length = detection_details.get('length', -1) + + code_lines_to_render = [] + document_content_lines = document.content.splitlines() + for line_index in range(lines_to_display): + current_line_index = start_line_index + line_index + if current_line_index >= len(document_content_lines): + break + + line_content = document_content_lines[current_line_index] + + line_with_detection = current_line_index == detection_line + if scan_type == consts.SECRET_SCAN_TYPE and line_with_detection and obfuscate: + violation = line_content[detection_position : detection_position + violation_length] + code_lines_to_render.append(line_content.replace(violation, obfuscate_text(violation))) + else: + code_lines_to_render.append(line_content) + + code_to_render = '\n'.join(code_lines_to_render) + return Syntax( + theme=_SYNTAX_HIGHLIGHT_THEME, + code=code_to_render, + lexer=Syntax.guess_lexer(document.path, code=code_to_render), + line_numbers=True, + dedent=True, + tab_size=2, + start_line=start_line_index + 1, + highlight_lines={ + detection_line + 1, + }, + ) + + +def _get_code_snippet_syntax_from_git_diff( + scan_type: str, detection: 'Detection', document: 'Document', obfuscate: bool +) -> Syntax: + detection_details = detection.detection_details + detection_line = _get_detection_line(scan_type, detection) + detection_position = detection_details.get('start_position', -1) + violation_length = detection_details.get('length', -1) + + line_content = document.content.splitlines()[detection_line] + detection_position_in_line = get_position_in_line(document.content, detection_position) + if scan_type == consts.SECRET_SCAN_TYPE and obfuscate: + violation = line_content[detection_position_in_line : detection_position_in_line + violation_length] + line_content = line_content.replace(violation, obfuscate_text(violation)) + + return Syntax( + theme=_SYNTAX_HIGHLIGHT_THEME, + code=line_content, + lexer='diff', + line_numbers=True, + start_line=detection_line, + dedent=True, + tab_size=2, + highlight_lines={detection_line + 1}, + ) + + +def _is_git_diff_based_scan(scan_type: str, command_scan_type: str) -> bool: + return ( + command_scan_type in consts.COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES + and scan_type in consts.COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES + ) + + +def get_code_snippet_syntax( + scan_type: str, + command_scan_type: str, + detection: 'Detection', + document: 'Document', + lines_to_display: int = 3, + obfuscate: bool = True, +) -> Syntax: + if _is_git_diff_based_scan(scan_type, command_scan_type): + # it will return syntax with just one line + return _get_code_snippet_syntax_from_git_diff(scan_type, detection, document, obfuscate) + + return _get_code_snippet_syntax_from_file(scan_type, detection, document, lines_to_display, obfuscate) diff --git a/cycode/cli/printers/utils/detection_data.py b/cycode/cli/printers/utils/detection_data.py new file mode 100644 index 00000000..66171226 --- /dev/null +++ b/cycode/cli/printers/utils/detection_data.py @@ -0,0 +1,16 @@ +from typing import TYPE_CHECKING + +from cycode.cli import consts + +if TYPE_CHECKING: + from cycode.cyclient.models import Detection + + +def get_detection_title(scan_type: str, detection: 'Detection') -> str: + title = detection.message + if scan_type == consts.SAST_SCAN_TYPE: + title = detection.detection_details['policy_display_name'] + elif scan_type == consts.SECRET_SCAN_TYPE: + title = f'Hardcoded {detection.type} is used' + + return title diff --git a/cycode/cli/printers/utils/detection_ordering/__init__.py b/cycode/cli/printers/utils/detection_ordering/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/printers/utils/detection_ordering/common_ordering.py b/cycode/cli/printers/utils/detection_ordering/common_ordering.py new file mode 100644 index 00000000..531cbc4c --- /dev/null +++ b/cycode/cli/printers/utils/detection_ordering/common_ordering.py @@ -0,0 +1,66 @@ +from collections import defaultdict +from typing import TYPE_CHECKING, List, Set, Tuple + +from cycode.cli.cli_types import SeverityOption + +if TYPE_CHECKING: + from cycode.cli.models import Document, LocalScanResult + from cycode.cyclient.models import Detection + + +GroupedDetections = Tuple[List[Tuple['Detection', 'Document']], Set[int]] + + +def __severity_sort_key(detection_with_document: Tuple['Detection', 'Document']) -> int: + detection, _ = detection_with_document + severity = detection.severity if detection.severity else '' + return SeverityOption.get_member_weight(severity) + + +def _sort_detections_by_severity( + detections_with_documents: List[Tuple['Detection', 'Document']], +) -> List[Tuple['Detection', 'Document']]: + return sorted(detections_with_documents, key=__severity_sort_key, reverse=True) + + +def __file_path_sort_key(detection_with_document: Tuple['Detection', 'Document']) -> str: + _, document = detection_with_document + return document.path + + +def _sort_detections_by_file_path( + detections_with_documents: List[Tuple['Detection', 'Document']], +) -> List[Tuple['Detection', 'Document']]: + return sorted(detections_with_documents, key=__file_path_sort_key) + + +def sort_and_group_detections( + detections_with_documents: List[Tuple['Detection', 'Document']], +) -> GroupedDetections: + """Sort detections by severity and group by file name.""" + detections = [] + group_separator_indexes = set() + + # we sort detections by file path to make persist output order + sorted_detections = _sort_detections_by_file_path(detections_with_documents) + + grouped_by_file_path = defaultdict(list) + for detection, document in sorted_detections: + grouped_by_file_path[document.path].append((detection, document)) + + for file_path_group in grouped_by_file_path.values(): + group_separator_indexes.add(len(detections) - 1) # indexing starts from 0 + detections.extend(_sort_detections_by_severity(file_path_group)) + + return detections, group_separator_indexes + + +def sort_and_group_detections_from_scan_result(local_scan_results: List['LocalScanResult']) -> GroupedDetections: + detections_with_documents = [] + for local_scan_result in local_scan_results: + for document_detections in local_scan_result.document_detections: + detections_with_documents.extend( + [(detection, document_detections.document) for detection in document_detections.detections] + ) + + return sort_and_group_detections(detections_with_documents) diff --git a/cycode/cli/printers/utils/detection_ordering/sca_ordering.py b/cycode/cli/printers/utils/detection_ordering/sca_ordering.py new file mode 100644 index 00000000..85915c56 --- /dev/null +++ b/cycode/cli/printers/utils/detection_ordering/sca_ordering.py @@ -0,0 +1,58 @@ +from collections import defaultdict +from typing import TYPE_CHECKING, Dict, List, Set, Tuple + +from cycode.cli.cli_types import SeverityOption + +if TYPE_CHECKING: + from cycode.cyclient.models import Detection + + +def __group_by(detections: List['Detection'], details_field_name: str) -> Dict[str, List['Detection']]: + grouped = defaultdict(list) + for detection in detections: + grouped[detection.detection_details.get(details_field_name)].append(detection) + return grouped + + +def __severity_sort_key(detection: 'Detection') -> int: + severity = detection.detection_details.get('advisory_severity', 'unknown') + return SeverityOption.get_member_weight(severity) + + +def _sort_detections_by_severity(detections: List['Detection']) -> List['Detection']: + return sorted(detections, key=__severity_sort_key, reverse=True) + + +def __package_sort_key(detection: 'Detection') -> int: + return detection.detection_details.get('package_name') + + +def _sort_detections_by_package(detections: List['Detection']) -> List['Detection']: + return sorted(detections, key=__package_sort_key) + + +def sort_and_group_detections(detections: List['Detection']) -> Tuple[List['Detection'], Set[int]]: + """Sort detections by severity and group by repository, code project and package name. + + Note: + Code Project is path to the manifest file. + + Grouping by code projects also groups by ecosystem. + Because manifest files are unique per ecosystem. + """ + resulting_detections = [] + group_separator_indexes = set() + + # we sort detections by package name to make persist output order + sorted_detections = _sort_detections_by_package(detections) + + grouped_by_repository = __group_by(sorted_detections, 'repository_name') + for repository_group in grouped_by_repository.values(): + grouped_by_code_project = __group_by(repository_group, 'file_name') + for code_project_group in grouped_by_code_project.values(): + grouped_by_package = __group_by(code_project_group, 'package_name') + for package_group in grouped_by_package.values(): + group_separator_indexes.add(len(resulting_detections) - 1) # indexing starts from 0 + resulting_detections.extend(_sort_detections_by_severity(package_group)) + + return resulting_detections, group_separator_indexes diff --git a/cycode/cli/printers/utils/rich_helpers.py b/cycode/cli/printers/utils/rich_helpers.py new file mode 100644 index 00000000..52d2a0f2 --- /dev/null +++ b/cycode/cli/printers/utils/rich_helpers.py @@ -0,0 +1,37 @@ +from typing import TYPE_CHECKING + +from rich.columns import Columns +from rich.markdown import Markdown +from rich.panel import Panel + +from cycode.cli.console import console + +if TYPE_CHECKING: + from rich.console import RenderableType + + +def get_panel(renderable: 'RenderableType', title: str) -> Panel: + return Panel( + renderable, + title=title, + title_align='left', + border_style='dim', + ) + + +def get_markdown_panel(markdown_text: str, title: str) -> Panel: + return get_panel( + Markdown(markdown_text.strip()), + title=title, + ) + + +def get_columns_in_1_to_3_ratio(left: 'Panel', right: 'Panel', panel_border_offset: int = 5) -> Columns: + terminal_width = console.width + one_third_width = terminal_width // 3 + two_thirds_width = terminal_width - one_third_width - panel_border_offset + + left.width = one_third_width + right.width = two_thirds_width + + return Columns([left, right]) diff --git a/tests/cli/commands/test_main_command.py b/tests/cli/commands/test_main_command.py index d9890268..ba791f2e 100644 --- a/tests/cli/commands/test_main_command.py +++ b/tests/cli/commands/test_main_command.py @@ -49,7 +49,7 @@ def test_passing_output_option(output: str, scan_client: 'ScanClient', api_token output = json.loads(result.output) assert 'scan_ids' in output else: - assert 'issue of type:' in result.output + assert 'violation:' in result.output @responses.activate From aadb5905abeb3d0407af2291a87e4bd59013eed6 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Fri, 18 Apr 2025 22:55:36 +0200 Subject: [PATCH 158/257] CM-46731 - Make all flows use scan service (#296) --- README.md | 1 - cycode/cli/apps/scan/code_scanner.py | 85 ++++------- cycode/cli/apps/scan/scan_command.py | 4 +- cycode/cyclient/models.py | 28 ---- cycode/cyclient/scan_client.py | 132 +++--------------- cycode/cyclient/scan_config_base.py | 28 +--- tests/cli/commands/test_main_command.py | 14 +- .../cyclient/mocked_responses/scan_client.py | 88 +----------- .../scan_config/test_default_scan_config.py | 5 +- .../scan_config/test_dev_scan_config.py | 5 +- tests/cyclient/test_scan_client.py | 110 ++++++++------- tests/test_code_scanner.py | 25 ---- 12 files changed, 127 insertions(+), 398 deletions(-) diff --git a/README.md b/README.md index b30bc533..6218cba8 100644 --- a/README.md +++ b/README.md @@ -303,7 +303,6 @@ The Cycode CLI application offers several types of scans so that you can choose | `--monitor` | When specified, the scan results will be recorded in the knowledge graph. Please note that when working in `monitor` mode, the knowledge graph will not be updated as a result of SCM events (Push, Repo creation). (Supported for SCA scan type only). | | `--report` | When specified, a violations report will be generated. A URL link to the report will be printed as an output to the command execution. | | `--no-restore` | When specified, Cycode will not run restore command. Will scan direct dependencies ONLY! | -| `--sync` | Run scan synchronously (the default is asynchronous). | | `--gradle-all-sub-projects` | When specified, Cycode will run gradle restore command for all sub projects. Should run from root project directory ONLY! | | `--help` | Show options for given command. | diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index 35208d59..67185dce 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -100,24 +100,30 @@ def set_issue_detected_by_scan_results(ctx: typer.Context, scan_results: List[Lo set_issue_detected(ctx, any(scan_result.issue_detected for scan_result in scan_results)) -def _should_use_scan_service(scan_type: str, scan_parameters: dict) -> bool: - return scan_type == consts.SECRET_SCAN_TYPE and scan_parameters.get('report') is True +def _should_use_sync_flow(command_scan_type: str, scan_type: str, sync_option: bool) -> bool: + """Decide whether to use sync flow or async flow for the scan. + Note: + Passing `--sync` option does not mean that sync flow will be used in all cases. -def _should_use_sync_flow( - command_scan_type: str, scan_type: str, sync_option: bool, scan_parameters: Optional[dict] = None -) -> bool: - if not sync_option: + The logic: + - for IAC scan, sync flow is always used + - for SAST scan, sync flow is not supported + - for SCA and Secrets scan, sync flow is supported only for path/repository scan + """ + if not sync_option and scan_type != consts.IAC_SCAN_TYPE: return False if command_scan_type not in {'path', 'repository'}: - raise ValueError(f'Sync flow is not available for "{command_scan_type}" command type. Remove --sync option.') + return False - if scan_type is consts.SAST_SCAN_TYPE: - raise ValueError('Sync scan is not available for SAST scan type.') + if scan_type == consts.IAC_SCAN_TYPE: + # sync in the only available flow for IAC scan; we do not use detector directly anymore + return True - if scan_parameters.get('report') is True: - raise ValueError('You can not use sync flow with report option. Either remove "report" or "sync" option.') + if scan_type is consts.SAST_SCAN_TYPE: # noqa: SIM103 + # SAST does not support sync flow + return False return True @@ -169,8 +175,7 @@ def _scan_batch_thread_func(batch: List[Document]) -> Tuple[str, CliError, Local scan_id = str(_generate_unique_id()) scan_completed = False - should_use_scan_service = _should_use_scan_service(scan_type, scan_parameters) - should_use_sync_flow = _should_use_sync_flow(command_scan_type, scan_type, sync_option, scan_parameters) + should_use_sync_flow = _should_use_sync_flow(command_scan_type, scan_type, sync_option) try: logger.debug('Preparing local files, %s', {'batch_files_count': len(batch)}) @@ -180,11 +185,9 @@ def _scan_batch_thread_func(batch: List[Document]) -> Tuple[str, CliError, Local cycode_client, zipped_documents, scan_type, - scan_id, is_git_diff, is_commit_range, scan_parameters, - should_use_scan_service, should_use_sync_flow, ) @@ -224,7 +227,6 @@ def _scan_batch_thread_func(batch: List[Document]) -> Tuple[str, CliError, Local zip_file_size, command_scan_type, error_message, - should_use_scan_service or should_use_sync_flow, # sync flow implies scan service ) return scan_id, error, local_scan_result @@ -456,24 +458,16 @@ def perform_scan( cycode_client: 'ScanClient', zipped_documents: 'InMemoryZip', scan_type: str, - scan_id: str, is_git_diff: bool, is_commit_range: bool, scan_parameters: dict, - should_use_scan_service: bool = False, should_use_sync_flow: bool = False, ) -> ZippedFileScanResult: if should_use_sync_flow: # it does not support commit range scans; should_use_sync_flow handles it return perform_scan_sync(cycode_client, zipped_documents, scan_type, scan_parameters, is_git_diff) - if scan_type in (consts.SCA_SCAN_TYPE, consts.SAST_SCAN_TYPE) or should_use_scan_service: - return perform_scan_async(cycode_client, zipped_documents, scan_type, scan_parameters, is_commit_range) - - if is_commit_range: - return cycode_client.commit_range_zipped_file_scan(scan_type, zipped_documents, scan_id) - - return cycode_client.zipped_file_scan(scan_type, zipped_documents, scan_id, scan_parameters, is_git_diff) + return perform_scan_async(cycode_client, zipped_documents, scan_type, scan_parameters, is_commit_range) def perform_scan_async( @@ -823,7 +817,6 @@ def _report_scan_status( zip_size: int, command_scan_type: str, error_message: Optional[str], - should_use_scan_service: bool = False, ) -> None: try: end_scan_time = time.time() @@ -840,12 +833,15 @@ def _report_scan_status( 'scan_type': scan_type, } - cycode_client.report_scan_status(scan_type, scan_id, scan_status, should_use_scan_service) + cycode_client.report_scan_status(scan_type, scan_id, scan_status) except Exception as e: logger.debug('Failed to report scan status', exc_info=e) def _generate_unique_id() -> UUID: + if 'PYTEST_TEST_UNIQUE_ID' in os.environ: + return UUID(os.environ['PYTEST_TEST_UNIQUE_ID']) + return uuid4() @@ -868,13 +864,13 @@ def _get_scan_result( if not scan_details.detections_count: return init_default_scan_result(scan_id) - scan_raw_detections = cycode_client.get_scan_raw_detections(scan_type, scan_id) + scan_raw_detections = cycode_client.get_scan_raw_detections(scan_id) return ZippedFileScanResult( did_detect=True, detections_per_file=_map_detections_per_file_and_commit_id(scan_type, scan_raw_detections), scan_id=scan_id, - report_url=_try_get_any_report_url_if_needed(cycode_client, scan_id, scan_type, scan_parameters), + report_url=_try_get_aggregation_report_url_if_needed(scan_parameters, cycode_client, scan_type), ) @@ -886,37 +882,6 @@ def init_default_scan_result(scan_id: str) -> ZippedFileScanResult: ) -def _try_get_any_report_url_if_needed( - cycode_client: 'ScanClient', - scan_id: str, - scan_type: str, - scan_parameters: dict, -) -> Optional[str]: - """Tries to get aggregation report URL if needed, otherwise tries to get report URL.""" - aggregation_report_url = None - if scan_parameters: - _try_get_report_url_if_needed(cycode_client, scan_id, scan_type, scan_parameters) - aggregation_report_url = _try_get_aggregation_report_url_if_needed(scan_parameters, cycode_client, scan_type) - - if aggregation_report_url: - return aggregation_report_url - - return _try_get_report_url_if_needed(cycode_client, scan_id, scan_type, scan_parameters) - - -def _try_get_report_url_if_needed( - cycode_client: 'ScanClient', scan_id: str, scan_type: str, scan_parameters: dict -) -> Optional[str]: - if not scan_parameters.get('report', False): - return None - - try: - report_url_response = cycode_client.get_scan_report_url(scan_id, scan_type) - return report_url_response.report_url - except Exception as e: - logger.debug('Failed to get report URL', exc_info=e) - - def _set_aggregation_report_url(ctx: typer.Context, aggregation_report_url: Optional[str] = None) -> None: ctx.obj['aggregation_report_url'] = aggregation_report_url diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 3ba7699b..84485c0b 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -54,7 +54,9 @@ def scan_command( ] = SeverityOption.INFO, sync: Annotated[ bool, - typer.Option('--sync', help='Run scan synchronously.', show_default='asynchronously'), + typer.Option( + '--sync', help='Run scan synchronously (INTERNAL FOR IDEs).', show_default='asynchronously', hidden=True + ), ] = False, report: Annotated[ bool, diff --git a/cycode/cyclient/models.py b/cycode/cyclient/models.py index 2433ef6c..2c0f53d7 100644 --- a/cycode/cyclient/models.py +++ b/cycode/cyclient/models.py @@ -59,19 +59,6 @@ def __init__(self, file_name: str, detections: List[Detection], commit_id: Optio self.commit_id = commit_id -class DetectionsPerFileSchema(Schema): - class Meta: - unknown = EXCLUDE - - file_name = fields.String() - detections = fields.List(fields.Nested(DetectionSchema)) - commit_id = fields.String(allow_none=True) - - @post_load - def build_dto(self, data: Dict[str, Any], **_) -> 'DetectionsPerFile': - return DetectionsPerFile(**data) - - class ZippedFileScanResult(Schema): def __init__( self, @@ -89,21 +76,6 @@ def __init__( self.err = err -class ZippedFileScanResultSchema(Schema): - class Meta: - unknown = EXCLUDE - - did_detect = fields.Boolean() - scan_id = fields.String() - report_url = fields.String(allow_none=True) - detections_per_file = fields.List(fields.Nested(DetectionsPerFileSchema)) - err = fields.String() - - @post_load - def build_dto(self, data: Dict[str, Any], **_) -> 'ZippedFileScanResult': - return ZippedFileScanResult(**data) - - class ScanResult(Schema): def __init__( self, diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index c6bfc57c..09908943 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -1,5 +1,6 @@ import json -from typing import TYPE_CHECKING, List, Optional, Set, Union +from copy import deepcopy +from typing import TYPE_CHECKING, List, Set, Union from uuid import UUID from requests import Response @@ -22,34 +23,12 @@ def __init__( self.scan_cycode_client = scan_cycode_client self.scan_config = scan_config - self._SCAN_SERVICE_CONTROLLER_PATH = 'api/v1/scan' self._SCAN_SERVICE_CLI_CONTROLLER_PATH = 'api/v1/cli-scan' - - self._DETECTIONS_SERVICE_CONTROLLER_PATH = 'api/v1/detections' self._DETECTIONS_SERVICE_CLI_CONTROLLER_PATH = 'api/v1/detections/cli' - self._POLICIES_SERVICE_CONTROLLER_PATH_V3 = 'api/v3/policies' self._hide_response_log = hide_response_log - def get_scan_controller_path(self, scan_type: str, should_use_scan_service: bool = False) -> str: - if not should_use_scan_service and scan_type == consts.IAC_SCAN_TYPE: - # we don't use async flow for IaC scan yet - return self._SCAN_SERVICE_CONTROLLER_PATH - if not should_use_scan_service and scan_type == consts.SECRET_SCAN_TYPE: - # if a secret scan goes to detector directly, we should not use CLI controller. - # CLI controller belongs to the scan service only - return self._SCAN_SERVICE_CONTROLLER_PATH - - return self._SCAN_SERVICE_CLI_CONTROLLER_PATH - - def get_detections_service_controller_path(self, scan_type: str) -> str: - if scan_type == consts.IAC_SCAN_TYPE: - # we don't use async flow for IaC scan yet - return self._DETECTIONS_SERVICE_CONTROLLER_PATH - - return self._DETECTIONS_SERVICE_CLI_CONTROLLER_PATH - @staticmethod def get_scan_flow_type(should_use_sync_flow: bool = False) -> str: if should_use_sync_flow: @@ -57,13 +36,10 @@ def get_scan_flow_type(should_use_sync_flow: bool = False) -> str: return '' - def get_scan_service_url_path( - self, scan_type: str, should_use_scan_service: bool = False, should_use_sync_flow: bool = False - ) -> str: - service_path = self.scan_config.get_service_name(scan_type, should_use_scan_service) - controller_path = self.get_scan_controller_path(scan_type, should_use_scan_service) + def get_scan_service_url_path(self, scan_type: str, should_use_sync_flow: bool = False) -> str: + service_path = self.scan_config.get_service_name(scan_type) flow_type = self.get_scan_flow_type(should_use_sync_flow) - return f'{service_path}/{controller_path}{flow_type}' + return f'{service_path}/{self._SCAN_SERVICE_CLI_CONTROLLER_PATH}{flow_type}' def content_scan(self, scan_type: str, file_name: str, content: str, is_git_diff: bool = True) -> models.ScanResult: path = f'{self.get_scan_service_url_path(scan_type)}/content' @@ -73,27 +49,6 @@ def content_scan(self, scan_type: str, file_name: str, content: str, is_git_diff ) return self.parse_scan_response(response) - def get_zipped_file_scan_url_path(self, scan_type: str) -> str: - return f'{self.get_scan_service_url_path(scan_type)}/zipped-file' - - def zipped_file_scan( - self, scan_type: str, zip_file: InMemoryZip, scan_id: str, scan_parameters: dict, is_git_diff: bool = False - ) -> models.ZippedFileScanResult: - files = {'file': ('multiple_files_scan.zip', zip_file.read())} - - response = self.scan_cycode_client.post( - url_path=self.get_zipped_file_scan_url_path(scan_type), - data={'scan_id': scan_id, 'is_git_diff': is_git_diff, 'scan_parameters': json.dumps(scan_parameters)}, - files=files, - hide_response_content_log=self._hide_response_log, - ) - - return self.parse_zipped_file_scan_response(response) - - def get_scan_report_url(self, scan_id: str, scan_type: str) -> models.ScanReportUrlResponse: - response = self.scan_cycode_client.get(url_path=self.get_scan_report_url_path(scan_id, scan_type)) - return models.ScanReportUrlResponseSchema().build_dto(response.json()) - def get_scan_aggregation_report_url(self, aggregation_id: str, scan_type: str) -> models.ScanReportUrlResponse: response = self.scan_cycode_client.get( url_path=self.get_scan_aggregation_report_url_path(aggregation_id, scan_type) @@ -103,16 +58,12 @@ def get_scan_aggregation_report_url(self, aggregation_id: str, scan_type: str) - def get_zipped_file_scan_async_url_path(self, scan_type: str, should_use_sync_flow: bool = False) -> str: async_scan_type = self.scan_config.get_async_scan_type(scan_type) async_entity_type = self.scan_config.get_async_entity_type(scan_type) - scan_service_url_path = self.get_scan_service_url_path( - scan_type, should_use_scan_service=True, should_use_sync_flow=should_use_sync_flow - ) + scan_service_url_path = self.get_scan_service_url_path(scan_type, should_use_sync_flow=should_use_sync_flow) return f'{scan_service_url_path}/{async_scan_type}/{async_entity_type}' def get_zipped_file_scan_sync_url_path(self, scan_type: str) -> str: server_scan_type = self.scan_config.get_async_scan_type(scan_type) - scan_service_url_path = self.get_scan_service_url_path( - scan_type, should_use_scan_service=True, should_use_sync_flow=True - ) + scan_service_url_path = self.get_scan_service_url_path(scan_type, should_use_sync_flow=True) return f'{scan_service_url_path}/{server_scan_type}/repository' def zipped_file_scan_sync( @@ -124,6 +75,7 @@ def zipped_file_scan_sync( ) -> models.ScanResultsSyncFlow: files = {'file': ('multiple_files_scan.zip', zip_file.read())} + scan_parameters = deepcopy(scan_parameters) # avoid mutating the original dict if 'report' in scan_parameters: del scan_parameters['report'] # BE raises validation error instead of ignoring it @@ -180,16 +132,10 @@ def multiple_zipped_file_scan_async( return models.ScanInitializationResponseSchema().load(response.json()) def get_scan_details_path(self, scan_type: str, scan_id: str) -> str: - return f'{self.get_scan_service_url_path(scan_type, should_use_scan_service=True)}/{scan_id}' - - def get_scan_report_url_path(self, scan_id: str, scan_type: str) -> str: - return f'{self.get_scan_service_url_path(scan_type, should_use_scan_service=True)}/reportUrl/{scan_id}' + return f'{self.get_scan_service_url_path(scan_type)}/{scan_id}' def get_scan_aggregation_report_url_path(self, aggregation_id: str, scan_type: str) -> str: - return ( - f'{self.get_scan_service_url_path(scan_type, should_use_scan_service=True)}' - f'/reportUrlByAggregationId/{aggregation_id}' - ) + return f'{self.get_scan_service_url_path(scan_type)}' f'/reportUrlByAggregationId/{aggregation_id}' def get_scan_details(self, scan_type: str, scan_id: str) -> models.ScanDetailsResponse: path = self.get_scan_details_path(scan_type, scan_id) @@ -256,21 +202,13 @@ def get_detection_rules(self, detection_rules_ids: Union[Set[str], List[str]]) - return self.parse_detection_rules_response(response) - def get_scan_detections_path(self, scan_type: str) -> str: - return f'{self.scan_config.get_detections_prefix()}/{self.get_detections_service_controller_path(scan_type)}' + def get_scan_detections_path(self) -> str: + return f'{self.scan_config.get_detections_prefix()}/{self._DETECTIONS_SERVICE_CLI_CONTROLLER_PATH}' - @staticmethod - def get_scan_detections_list_path_suffix(scan_type: str) -> str: - # we don't use async flow for IaC scan yet - if scan_type == consts.IAC_SCAN_TYPE: - return '' - - return '/detections' + def get_scan_detections_list_path(self) -> str: + return f'{self.get_scan_detections_path()}/detections' - def get_scan_detections_list_path(self, scan_type: str) -> str: - return f'{self.get_scan_detections_path(scan_type)}{self.get_scan_detections_list_path_suffix(scan_type)}' - - def get_scan_raw_detections(self, scan_type: str, scan_id: str) -> List[dict]: + def get_scan_raw_detections(self, scan_id: str) -> List[dict]: params = {'scan_id': scan_id} page_size = 200 @@ -284,7 +222,7 @@ def get_scan_raw_detections(self, scan_type: str, scan_id: str) -> List[dict]: params['page_number'] = page_number response = self.scan_cycode_client.get( - url_path=self.get_scan_detections_list_path(scan_type), + url_path=self.get_scan_detections_list_path(), params=params, hide_response_content_log=self._hide_response_log, ).json() @@ -295,45 +233,15 @@ def get_scan_raw_detections(self, scan_type: str, scan_id: str) -> List[dict]: return raw_detections - def commit_range_zipped_file_scan( - self, scan_type: str, zip_file: InMemoryZip, scan_id: str - ) -> models.ZippedFileScanResult: - url_path = f'{self.get_scan_service_url_path(scan_type)}/commit-range-zipped-file' - files = {'file': ('multiple_files_scan.zip', zip_file.read())} - response = self.scan_cycode_client.post( - url_path=url_path, data={'scan_id': scan_id}, files=files, hide_response_content_log=self._hide_response_log - ) - return self.parse_zipped_file_scan_response(response) + def get_report_scan_status_path(self, scan_type: str, scan_id: str) -> str: + return f'{self.get_scan_service_url_path(scan_type)}/{scan_id}/status' - def get_report_scan_status_path(self, scan_type: str, scan_id: str, should_use_scan_service: bool = False) -> str: - return f'{self.get_scan_service_url_path(scan_type, should_use_scan_service)}/{scan_id}/status' - - def report_scan_status( - self, scan_type: str, scan_id: str, scan_status: dict, should_use_scan_service: bool = False - ) -> None: + def report_scan_status(self, scan_type: str, scan_id: str, scan_status: dict) -> None: self.scan_cycode_client.post( - url_path=self.get_report_scan_status_path( - scan_type, scan_id, should_use_scan_service=should_use_scan_service - ), + url_path=self.get_report_scan_status_path(scan_type, scan_id), body=scan_status, ) @staticmethod def parse_scan_response(response: Response) -> models.ScanResult: return models.ScanResultSchema().load(response.json()) - - @staticmethod - def parse_zipped_file_scan_response(response: Response) -> models.ZippedFileScanResult: - return models.ZippedFileScanResultSchema().load(response.json()) - - @staticmethod - def get_service_name(scan_type: str) -> Optional[str]: - # TODO(MarshalX): get_service_name should be removed from ScanClient? Because it exists in ScanConfig - if scan_type == consts.SECRET_SCAN_TYPE: - return 'secret' - if scan_type == consts.IAC_SCAN_TYPE: - return 'iac' - if scan_type == consts.SCA_SCAN_TYPE or scan_type == consts.SAST_SCAN_TYPE: - return 'scans' - - return None diff --git a/cycode/cyclient/scan_config_base.py b/cycode/cyclient/scan_config_base.py index 6dfa97ef..d60068ce 100644 --- a/cycode/cyclient/scan_config_base.py +++ b/cycode/cyclient/scan_config_base.py @@ -5,7 +5,7 @@ class ScanConfigBase(ABC): @abstractmethod - def get_service_name(self, scan_type: str, should_use_scan_service: bool = False) -> str: ... + def get_service_name(self, scan_type: str) -> str: ... @staticmethod def get_async_scan_type(scan_type: str) -> str: @@ -28,32 +28,16 @@ def get_detections_prefix(self) -> str: ... class DevScanConfig(ScanConfigBase): - def get_service_name(self, scan_type: str, should_use_scan_service: bool = False) -> str: - if should_use_scan_service: - return '5004' - if scan_type == consts.SECRET_SCAN_TYPE: - return '5025' - if scan_type == consts.IAC_SCAN_TYPE: - return '5026' - - # sca and sast - return '5004' + def get_service_name(self, scan_type: str) -> str: + return '5004' # scan service def get_detections_prefix(self) -> str: - return '5016' + return '5016' # detections service class DefaultScanConfig(ScanConfigBase): - def get_service_name(self, scan_type: str, should_use_scan_service: bool = False) -> str: - if should_use_scan_service: - return 'scans' - if scan_type == consts.SECRET_SCAN_TYPE: - return 'secret' - if scan_type == consts.IAC_SCAN_TYPE: - return 'iac' - - # sca and sast - return 'scans' + def get_service_name(self, scan_type: str) -> str: + return 'scans' # scan service def get_detections_prefix(self) -> str: return 'detections' diff --git a/tests/cli/commands/test_main_command.py b/tests/cli/commands/test_main_command.py index ba791f2e..db8fe86b 100644 --- a/tests/cli/commands/test_main_command.py +++ b/tests/cli/commands/test_main_command.py @@ -11,8 +11,7 @@ from cycode.cli.cli_types import OutputTypeOption from cycode.cli.utils.git_proxy import git_proxy from tests.conftest import CLI_ENV_VARS, TEST_FILES_PATH, ZIP_CONTENT_PATH -from tests.cyclient.mocked_responses.scan_client import mock_scan_responses -from tests.cyclient.test_scan_client import get_zipped_file_scan_response, get_zipped_file_scan_url +from tests.cyclient.mocked_responses.scan_client import mock_scan_async_responses _PATH_TO_SCAN = TEST_FILES_PATH.joinpath('zip_content').absolute() @@ -34,12 +33,12 @@ def test_passing_output_option(output: str, scan_client: 'ScanClient', api_token scan_type = consts.SECRET_SCAN_TYPE scan_id = uuid4() - mock_scan_responses(responses, scan_type, scan_client, scan_id, ZIP_CONTENT_PATH) - responses.add(get_zipped_file_scan_response(get_zipped_file_scan_url(scan_type, scan_client), ZIP_CONTENT_PATH)) + mock_scan_async_responses(responses, scan_type, scan_client, scan_id, ZIP_CONTENT_PATH) responses.add(api_token_response) args = ['--output', output, 'scan', '--soft-fail', 'path', str(_PATH_TO_SCAN)] - result = CliRunner().invoke(app, args, env=CLI_ENV_VARS) + env = {'PYTEST_TEST_UNIQUE_ID': str(scan_id), **CLI_ENV_VARS} + result = CliRunner().invoke(app, args, env=env) except_json = output == 'json' @@ -54,10 +53,7 @@ def test_passing_output_option(output: str, scan_client: 'ScanClient', api_token @responses.activate def test_optional_git_with_path_scan(scan_client: 'ScanClient', api_token_response: responses.Response) -> None: - mock_scan_responses(responses, consts.SECRET_SCAN_TYPE, scan_client, uuid4(), ZIP_CONTENT_PATH) - responses.add( - get_zipped_file_scan_response(get_zipped_file_scan_url(consts.SECRET_SCAN_TYPE, scan_client), ZIP_CONTENT_PATH) - ) + mock_scan_async_responses(responses, consts.SECRET_SCAN_TYPE, scan_client, uuid4(), ZIP_CONTENT_PATH) responses.add(api_token_response) # fake env without Git executable diff --git a/tests/cyclient/mocked_responses/scan_client.py b/tests/cyclient/mocked_responses/scan_client.py index 87643001..1726e74c 100644 --- a/tests/cyclient/mocked_responses/scan_client.py +++ b/tests/cyclient/mocked_responses/scan_client.py @@ -9,53 +9,6 @@ from tests.conftest import MOCKED_RESPONSES_PATH -def get_zipped_file_scan_url(scan_type: str, scan_client: ScanClient) -> str: - api_url = scan_client.scan_cycode_client.api_url - service_url = scan_client.get_zipped_file_scan_url_path(scan_type) - return f'{api_url}/{service_url}' - - -def get_zipped_file_scan_response( - url: str, zip_content_path: Path, scan_id: Optional[UUID] = None -) -> responses.Response: - if not scan_id: - scan_id = uuid4() - - json_response = { - 'did_detect': True, - 'scan_id': str(scan_id), # not always as expected due to _get_scan_id and passing scan_id to cxt of CLI - 'detections_per_file': [ - { - 'file_name': str(zip_content_path.joinpath('secrets.py')), - 'commit_id': None, - 'detections': [ - { - 'detection_type_id': '12345678-418f-47ee-abb0-012345678901', - 'detection_rule_id': '12345678-aea1-4304-a6e9-012345678901', - 'message': "Secret of type 'Slack Token' was found in filename 'secrets.py'", - 'type': 'slack-token', - 'is_research': False, - 'detection_details': { - 'sha512': 'sha hash', - 'length': 55, - 'start_position': 19, - 'line': 0, - 'committed_at': '0001-01-01T00:00:00+00:00', - 'file_path': str(zip_content_path), - 'file_name': 'secrets.py', - 'file_extension': '.py', - 'should_resolve_upon_branch_deletion': False, - }, - } - ], - } - ], - 'report_url': None, - } - - return responses.Response(method=responses.POST, url=url, json=json_response, status=200) - - def get_zipped_file_scan_async_url(scan_type: str, scan_client: ScanClient) -> str: api_url = scan_client.scan_cycode_client.api_url service_url = scan_client.get_zipped_file_scan_async_url_path(scan_type) @@ -73,15 +26,9 @@ def get_zipped_file_scan_async_response(url: str, scan_id: Optional[UUID] = None return responses.Response(method=responses.POST, url=url, json=json_response, status=200) -def get_scan_details_url(scan_id: Optional[UUID], scan_client: ScanClient) -> str: - api_url = scan_client.scan_cycode_client.api_url - service_url = scan_client.get_scan_details_path(str(scan_id)) - return f'{api_url}/{service_url}' - - -def get_scan_report_url(scan_id: Optional[UUID], scan_client: ScanClient, scan_type: str) -> str: +def get_scan_details_url(scan_type: str, scan_id: Optional[UUID], scan_client: ScanClient) -> str: api_url = scan_client.scan_cycode_client.api_url - service_url = scan_client.get_scan_report_url_path(str(scan_id), scan_type) + service_url = scan_client.get_scan_details_path(scan_type, str(scan_id)) return f'{api_url}/{service_url}' @@ -91,14 +38,6 @@ def get_scan_aggregation_report_url(aggregation_id: Optional[UUID], scan_client: return f'{api_url}/{service_url}' -def get_scan_report_url_response(url: str, scan_id: Optional[UUID] = None) -> responses.Response: - if not scan_id: - scan_id = uuid4() - json_response = {'report_url': f'https://app.domain/on-demand-scans/{scan_id}'} - - return responses.Response(method=responses.GET, url=url, json=json_response, status=200) - - def get_scan_aggregation_report_url_response(url: str, aggregation_id: Optional[UUID] = None) -> responses.Response: if not aggregation_id: aggregation_id = uuid4() @@ -135,10 +74,10 @@ def get_scan_details_response(url: str, scan_id: Optional[UUID] = None) -> respo return responses.Response(method=responses.GET, url=url, json=json_response, status=200) -def get_scan_detections_url(scan_client: ScanClient, scan_type: str) -> str: +def get_scan_detections_url(scan_client: ScanClient) -> str: api_url = scan_client.scan_cycode_client.api_url - service_url = scan_client.get_scan_detections_path(scan_type) - return f'{api_url}/{service_url}' + path = scan_client.get_scan_detections_list_path() + return f'{api_url}/{path}' def get_scan_detections_response(url: str, scan_id: UUID, zip_content_path: Path) -> responses.Response: @@ -181,20 +120,7 @@ def mock_scan_async_responses( responses_module.add( get_zipped_file_scan_async_response(get_zipped_file_scan_async_url(scan_type, scan_client), scan_id) ) - responses_module.add(get_scan_details_response(get_scan_details_url(scan_id, scan_client), scan_id)) - responses_module.add(get_detection_rules_response(get_detection_rules_url(scan_client))) - responses_module.add( - get_scan_detections_response(get_scan_detections_url(scan_client, scan_type), scan_id, zip_content_path) - ) - responses_module.add(get_report_scan_status_response(get_report_scan_status_url(scan_type, scan_id, scan_client))) - - -def mock_scan_responses( - responses_module: responses, scan_type: str, scan_client: ScanClient, scan_id: UUID, zip_content_path: Path -) -> None: - responses_module.add( - get_zipped_file_scan_response(get_zipped_file_scan_url(scan_type, scan_client), zip_content_path) - ) + responses_module.add(get_scan_details_response(get_scan_details_url(scan_type, scan_id, scan_client), scan_id)) responses_module.add(get_detection_rules_response(get_detection_rules_url(scan_client))) + responses_module.add(get_scan_detections_response(get_scan_detections_url(scan_client), scan_id, zip_content_path)) responses_module.add(get_report_scan_status_response(get_report_scan_status_url(scan_type, scan_id, scan_client))) - responses_module.add(get_scan_report_url_response(get_scan_report_url(scan_id, scan_client, scan_type))) diff --git a/tests/cyclient/scan_config/test_default_scan_config.py b/tests/cyclient/scan_config/test_default_scan_config.py index 7371250c..987c6c78 100644 --- a/tests/cyclient/scan_config/test_default_scan_config.py +++ b/tests/cyclient/scan_config/test_default_scan_config.py @@ -5,11 +5,10 @@ def test_get_service_name() -> None: default_scan_config = DefaultScanConfig() - assert default_scan_config.get_service_name(consts.SECRET_SCAN_TYPE) == 'secret' - assert default_scan_config.get_service_name(consts.IAC_SCAN_TYPE) == 'iac' + assert default_scan_config.get_service_name(consts.SECRET_SCAN_TYPE) == 'scans' + assert default_scan_config.get_service_name(consts.IAC_SCAN_TYPE) == 'scans' assert default_scan_config.get_service_name(consts.SCA_SCAN_TYPE) == 'scans' assert default_scan_config.get_service_name(consts.SAST_SCAN_TYPE) == 'scans' - assert default_scan_config.get_service_name(consts.SECRET_SCAN_TYPE, True) == 'scans' def test_get_detections_prefix() -> None: diff --git a/tests/cyclient/scan_config/test_dev_scan_config.py b/tests/cyclient/scan_config/test_dev_scan_config.py index 6ebb368b..f1cd484c 100644 --- a/tests/cyclient/scan_config/test_dev_scan_config.py +++ b/tests/cyclient/scan_config/test_dev_scan_config.py @@ -5,11 +5,10 @@ def test_get_service_name() -> None: dev_scan_config = DevScanConfig() - assert dev_scan_config.get_service_name(consts.SECRET_SCAN_TYPE) == '5025' - assert dev_scan_config.get_service_name(consts.IAC_SCAN_TYPE) == '5026' + assert dev_scan_config.get_service_name(consts.SECRET_SCAN_TYPE) == '5004' + assert dev_scan_config.get_service_name(consts.IAC_SCAN_TYPE) == '5004' assert dev_scan_config.get_service_name(consts.SCA_SCAN_TYPE) == '5004' assert dev_scan_config.get_service_name(consts.SAST_SCAN_TYPE) == '5004' - assert dev_scan_config.get_service_name(consts.SECRET_SCAN_TYPE, should_use_scan_service=True) == '5004' def test_get_detections_prefix() -> None: diff --git a/tests/cyclient/test_scan_client.py b/tests/cyclient/test_scan_client.py index a1c0d151..d81116fb 100644 --- a/tests/cyclient/test_scan_client.py +++ b/tests/cyclient/test_scan_client.py @@ -5,10 +5,8 @@ import pytest import requests import responses -from requests import Timeout -from requests.exceptions import ProxyError +from requests.exceptions import ConnectionError as RequestsConnectionError -from cycode.cli import consts from cycode.cli.cli_types import ScanTypeOption from cycode.cli.exceptions.custom_exceptions import ( CycodeError, @@ -17,20 +15,21 @@ RequestTimeout, ) from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip -from cycode.cli.files_collector.zip_documents import zip_documents from cycode.cli.models import Document from cycode.cyclient.scan_client import ScanClient from tests.conftest import ZIP_CONTENT_PATH from tests.cyclient.mocked_responses.scan_client import ( - get_scan_report_url, - get_scan_report_url_response, - get_zipped_file_scan_response, - get_zipped_file_scan_url, + get_scan_aggregation_report_url, + get_scan_aggregation_report_url_response, + get_scan_details_response, + get_scan_details_url, + get_zipped_file_scan_async_response, + get_zipped_file_scan_async_url, ) def zip_scan_resources(scan_type: ScanTypeOption, scan_client: ScanClient) -> Tuple[str, InMemoryZip]: - url = get_zipped_file_scan_url(scan_type, scan_client) + url = get_zipped_file_scan_async_url(scan_type, scan_client) zip_file = get_test_zip_file(scan_type) return url, zip_file @@ -45,32 +44,25 @@ def get_test_zip_file(scan_type: ScanTypeOption) -> InMemoryZip: with open(path, 'r', encoding='UTF-8') as f: test_documents.append(Document(path, f.read(), is_git_diff_format=False)) - return zip_documents(scan_type, test_documents) - + from cycode.cli.files_collector.zip_documents import zip_documents -def test_get_service_name(scan_client: ScanClient) -> None: - # TODO(MarshalX): get_service_name should be removed from ScanClient? Because it exists in ScanConfig - assert scan_client.get_service_name(consts.SECRET_SCAN_TYPE) == 'secret' - assert scan_client.get_service_name(consts.IAC_SCAN_TYPE) == 'iac' - assert scan_client.get_service_name(consts.SCA_SCAN_TYPE) == 'scans' - assert scan_client.get_service_name(consts.SAST_SCAN_TYPE) == 'scans' + return zip_documents(scan_type, test_documents) @pytest.mark.parametrize('scan_type', list(ScanTypeOption)) @responses.activate -def test_zipped_file_scan( +def test_zipped_file_scan_async( scan_type: ScanTypeOption, scan_client: ScanClient, api_token_response: responses.Response ) -> None: + """Test the zipped_file_scan_async method for the async flow.""" url, zip_file = zip_scan_resources(scan_type, scan_client) expected_scan_id = uuid4() responses.add(api_token_response) # mock token based client - responses.add(get_zipped_file_scan_response(url, ZIP_CONTENT_PATH, expected_scan_id)) + responses.add(get_zipped_file_scan_async_response(url, expected_scan_id)) - zipped_file_scan_response = scan_client.zipped_file_scan( - scan_type, zip_file, scan_id=str(expected_scan_id), scan_parameters={} - ) - assert zipped_file_scan_response.scan_id == str(expected_scan_id) + scan_initialization_response = scan_client.zipped_file_scan_async(zip_file, scan_type, scan_parameters={}) + assert scan_initialization_response.scan_id == str(expected_scan_id) @pytest.mark.parametrize('scan_type', list(ScanTypeOption)) @@ -78,40 +70,41 @@ def test_zipped_file_scan( def test_get_scan_report_url( scan_type: ScanTypeOption, scan_client: ScanClient, api_token_response: responses.Response ) -> None: + """Test getting the scan report URL for the async flow.""" scan_id = uuid4() - url = get_scan_report_url(scan_id, scan_client, scan_type) + url = get_scan_aggregation_report_url(scan_id, scan_client, scan_type) responses.add(api_token_response) # mock token based client - responses.add(get_scan_report_url_response(url, scan_id)) + responses.add(get_scan_aggregation_report_url_response(url, scan_id)) - scan_report_url_response = scan_client.get_scan_report_url(str(scan_id), scan_type) - assert scan_report_url_response.report_url == 'https://app.domain/on-demand-scans/{scan_id}'.format(scan_id=scan_id) + scan_report_url_response = scan_client.get_scan_aggregation_report_url(str(scan_id), scan_type) + assert scan_report_url_response.report_url == f'https://app.domain/cli-logs-aggregation/{scan_id}' @pytest.mark.parametrize('scan_type', list(ScanTypeOption)) @responses.activate -def test_zipped_file_scan_unauthorized_error( +def test_zipped_file_scan_async_unauthorized_error( scan_type: ScanTypeOption, scan_client: ScanClient, api_token_response: responses.Response ) -> None: + """Test handling of unauthorized errors in the async flow.""" url, zip_file = zip_scan_resources(scan_type, scan_client) - expected_scan_id = uuid4().hex responses.add(api_token_response) # mock token based client - responses.add(method=responses.POST, url=url, status=401) + responses.add(method=responses.POST, url=url, status=401, body='Unauthorized') with pytest.raises(HttpUnauthorizedError) as e_info: - scan_client.zipped_file_scan(scan_type, zip_file, scan_id=expected_scan_id, scan_parameters={}) + scan_client.zipped_file_scan_async(zip_file=zip_file, scan_type=scan_type, scan_parameters={}) assert e_info.value.status_code == 401 @pytest.mark.parametrize('scan_type', list(ScanTypeOption)) @responses.activate -def test_zipped_file_scan_bad_request_error( +def test_zipped_file_scan_async_bad_request_error( scan_type: ScanTypeOption, scan_client: ScanClient, api_token_response: responses.Response ) -> None: + """Test handling of bad request errors in the async flow.""" url, zip_file = zip_scan_resources(scan_type, scan_client) - expected_scan_id = uuid4().hex expected_status_code = 400 expected_response_text = 'Bad Request' @@ -120,7 +113,7 @@ def test_zipped_file_scan_bad_request_error( responses.add(method=responses.POST, url=url, status=expected_status_code, body=expected_response_text) with pytest.raises(CycodeError) as e_info: - scan_client.zipped_file_scan(scan_type, zip_file, scan_id=expected_scan_id, scan_parameters={}) + scan_client.zipped_file_scan_async(zip_file=zip_file, scan_type=scan_type, scan_parameters={}) assert e_info.value.status_code == expected_status_code assert e_info.value.error_message == expected_response_text @@ -128,40 +121,51 @@ def test_zipped_file_scan_bad_request_error( @pytest.mark.parametrize('scan_type', list(ScanTypeOption)) @responses.activate -def test_zipped_file_scan_timeout_error( +def test_zipped_file_scan_async_timeout_error( scan_type: ScanTypeOption, scan_client: ScanClient, api_token_response: responses.Response ) -> None: - scan_url, zip_file = zip_scan_resources(scan_type, scan_client) - expected_scan_id = uuid4().hex - - responses.add(responses.POST, scan_url, status=504) - - timeout_response = requests.post(scan_url, timeout=5) - if timeout_response.status_code == 504: - """bypass SAST""" - - responses.reset() + """Test handling of timeout errors in the async flow.""" + url, zip_file = zip_scan_resources(scan_type, scan_client) - timeout_error = Timeout() - timeout_error.response = timeout_response + timeout_error = requests.exceptions.Timeout('Connection timed out') responses.add(api_token_response) # mock token based client - responses.add(method=responses.POST, url=scan_url, body=timeout_error, status=504) + responses.add(method=responses.POST, url=url, body=timeout_error) with pytest.raises(RequestTimeout): - scan_client.zipped_file_scan(scan_type, zip_file, scan_id=expected_scan_id, scan_parameters={}) + scan_client.zipped_file_scan_async(zip_file=zip_file, scan_type=scan_type, scan_parameters={}) @pytest.mark.parametrize('scan_type', list(ScanTypeOption)) @responses.activate -def test_zipped_file_scan_connection_error( +def test_zipped_file_scan_async_connection_error( scan_type: ScanTypeOption, scan_client: ScanClient, api_token_response: responses.Response ) -> None: + """Test handling of connection errors in the async flow.""" url, zip_file = zip_scan_resources(scan_type, scan_client) - expected_scan_id = uuid4().hex + + # Create a connection error response + connection_error = RequestsConnectionError('Connection refused') responses.add(api_token_response) # mock token based client - responses.add(method=responses.POST, url=url, body=ProxyError()) + responses.add(method=responses.POST, url=url, body=connection_error) with pytest.raises(RequestConnectionError): - scan_client.zipped_file_scan(scan_type, zip_file, scan_id=expected_scan_id, scan_parameters={}) + scan_client.zipped_file_scan_async(zip_file=zip_file, scan_type=scan_type, scan_parameters={}) + + +@pytest.mark.parametrize('scan_type', list(ScanTypeOption)) +@responses.activate +def test_get_scan_details( + scan_type: ScanTypeOption, scan_client: ScanClient, api_token_response: responses.Response +) -> None: + """Test getting scan details in the async flow.""" + scan_id = uuid4() + url = get_scan_details_url(scan_type, scan_id, scan_client) + + responses.add(api_token_response) # mock token based client + responses.add(get_scan_details_response(url, scan_id)) + + scan_details_response = scan_client.get_scan_details(scan_type, str(scan_id)) + assert scan_details_response.id == str(scan_id) + assert scan_details_response.scan_status == 'Completed' diff --git a/tests/test_code_scanner.py b/tests/test_code_scanner.py index d16aad82..9ef09123 100644 --- a/tests/test_code_scanner.py +++ b/tests/test_code_scanner.py @@ -7,7 +7,6 @@ from cycode.cli import consts from cycode.cli.apps.scan.code_scanner import ( _try_get_aggregation_report_url_if_needed, - _try_get_report_url_if_needed, ) from cycode.cli.cli_types import ScanTypeOption from cycode.cli.files_collector.excluder import _is_relevant_file_to_scan @@ -16,8 +15,6 @@ from tests.cyclient.mocked_responses.scan_client import ( get_scan_aggregation_report_url, get_scan_aggregation_report_url_response, - get_scan_report_url, - get_scan_report_url_response, ) @@ -26,28 +23,6 @@ def test_is_relevant_file_to_scan_sca() -> None: assert _is_relevant_file_to_scan(consts.SCA_SCAN_TYPE, path) is True -@pytest.mark.parametrize('scan_type', list(ScanTypeOption)) -def test_try_get_report_url_if_needed_return_none(scan_type: ScanTypeOption, scan_client: ScanClient) -> None: - scan_id = uuid4().hex - result = _try_get_report_url_if_needed(scan_client, scan_id, consts.SECRET_SCAN_TYPE, scan_parameters={}) - assert result is None - - -@pytest.mark.parametrize('scan_type', list(ScanTypeOption)) -@responses.activate -def test_try_get_report_url_if_needed_return_result( - scan_type: ScanTypeOption, scan_client: ScanClient, api_token_response: responses.Response -) -> None: - scan_id = uuid4() - url = get_scan_report_url(scan_id, scan_client, scan_type) - responses.add(api_token_response) # mock token based client - responses.add(get_scan_report_url_response(url, scan_id)) - - scan_report_url_response = scan_client.get_scan_report_url(str(scan_id), scan_type) - result = _try_get_report_url_if_needed(scan_client, str(scan_id), scan_type, scan_parameters={'report': True}) - assert result == scan_report_url_response.report_url - - @pytest.mark.parametrize('scan_type', list(ScanTypeOption)) def test_try_get_aggregation_report_url_if_no_report_command_needed_return_none( scan_type: ScanTypeOption, scan_client: ScanClient From 6c70c2118ccdf43ce41bd090744474fad728ec97 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 23 Apr 2025 12:30:34 +0200 Subject: [PATCH 159/257] CM-46733 - Add CLI output exporting in HTML, SVG, and JSON formats (#297) --- cycode/cli/app.py | 37 +++++- cycode/cli/apps/ai_remediation/apply_fix.py | 3 +- .../apps/ai_remediation/print_remediation.py | 3 +- cycode/cli/apps/auth/auth_command.py | 4 +- cycode/cli/apps/auth/auth_common.py | 5 +- cycode/cli/apps/auth/check_command.py | 3 +- cycode/cli/apps/scan/code_scanner.py | 6 +- cycode/cli/cli_types.py | 6 + cycode/cli/exceptions/handle_errors.py | 8 +- cycode/cli/printers/console_printer.py | 105 ++++++++++++++---- cycode/cli/printers/json_printer.py | 7 +- cycode/cli/printers/printer_base.py | 19 +++- cycode/cli/printers/rich_printer.py | 10 +- .../cli/printers/tables/sca_table_printer.py | 6 +- .../cli/printers/tables/table_printer_base.py | 24 ++-- cycode/cli/printers/text_printer.py | 37 +++--- .../cli/exceptions/test_handle_scan_errors.py | 6 +- 17 files changed, 196 insertions(+), 93 deletions(-) diff --git a/cycode/cli/app.py b/cycode/cli/app.py index aed0e172..5c83be9e 100644 --- a/cycode/cli/app.py +++ b/cycode/cli/app.py @@ -1,4 +1,5 @@ import logging +from pathlib import Path from typing import Annotated, Optional import typer @@ -6,8 +7,9 @@ from cycode import __version__ from cycode.cli.apps import ai_remediation, auth, configure, ignore, report, scan, status -from cycode.cli.cli_types import OutputTypeOption +from cycode.cli.cli_types import ExportTypeOption, OutputTypeOption from cycode.cli.consts import CLI_CONTEXT_SETTINGS +from cycode.cli.printers import ConsolePrinter from cycode.cli.user_settings.configuration_manager import ConfigurationManager from cycode.cli.utils.progress_bar import SCAN_PROGRESS_BAR_SECTIONS, get_progress_bar from cycode.cli.utils.sentry import add_breadcrumb, init_sentry @@ -44,7 +46,14 @@ def check_latest_version_on_close(ctx: typer.Context) -> None: version_checker.check_and_notify_update(current_version=__version__, use_cache=should_use_cache) +def export_if_needed_on_close(ctx: typer.Context) -> None: + printer = ctx.obj.get('console_printer') + if printer.is_recording: + printer.export() + + _COMPLETION_RICH_HELP_PANEL = 'Completion options' +_EXPORT_RICH_HELP_PANEL = 'Export options' @app.callback() @@ -64,6 +73,27 @@ def app_callback( Optional[str], typer.Option(hidden=True, help='Characteristic JSON object that lets servers identify the application.'), ] = None, + export_type: Annotated[ + ExportTypeOption, + typer.Option( + '--export-type', + case_sensitive=False, + help='Specify the export type. ' + 'HTML and SVG will export terminal output and rely on --output option. ' + 'JSON always exports JSON.', + rich_help_panel=_EXPORT_RICH_HELP_PANEL, + ), + ] = ExportTypeOption.JSON, + export_file: Annotated[ + Optional[Path], + typer.Option( + '--export-file', + help='Export file. Path to the file where the export will be saved. ', + dir_okay=False, + writable=True, + rich_help_panel=_EXPORT_RICH_HELP_PANEL, + ), + ] = None, _: Annotated[ Optional[bool], typer.Option( @@ -104,6 +134,11 @@ def app_callback( ctx.obj['progress_bar'] = get_progress_bar(hidden=no_progress_meter, sections=SCAN_PROGRESS_BAR_SECTIONS) + ctx.obj['export_type'] = export_type + ctx.obj['export_file'] = export_file + ctx.obj['console_printer'] = ConsolePrinter(ctx) + ctx.call_on_close(lambda: export_if_needed_on_close(ctx)) + if user_agent: user_agent_option = UserAgentOptionScheme().loads(user_agent) CycodeClientBase.enrich_user_agent(user_agent_option.user_agent_suffix) diff --git a/cycode/cli/apps/ai_remediation/apply_fix.py b/cycode/cli/apps/ai_remediation/apply_fix.py index e0c2599b..bd840411 100644 --- a/cycode/cli/apps/ai_remediation/apply_fix.py +++ b/cycode/cli/apps/ai_remediation/apply_fix.py @@ -4,11 +4,10 @@ from patch_ng import fromstring from cycode.cli.models import CliResult -from cycode.cli.printers import ConsolePrinter def apply_fix(ctx: typer.Context, diff: str, is_fix_available: bool) -> None: - printer = ConsolePrinter(ctx) + printer = ctx.obj.get('console_printer') if not is_fix_available: printer.print_result(CliResult(success=False, message='Fix is not available for this violation')) return diff --git a/cycode/cli/apps/ai_remediation/print_remediation.py b/cycode/cli/apps/ai_remediation/print_remediation.py index c0109341..92272b76 100644 --- a/cycode/cli/apps/ai_remediation/print_remediation.py +++ b/cycode/cli/apps/ai_remediation/print_remediation.py @@ -3,11 +3,10 @@ from cycode.cli.console import console from cycode.cli.models import CliResult -from cycode.cli.printers import ConsolePrinter def print_remediation(ctx: typer.Context, remediation_markdown: str, is_fix_available: bool) -> None: - printer = ConsolePrinter(ctx) + printer = ctx.obj.get('console_printer') if printer.is_json_printer: data = {'remediation': remediation_markdown, 'is_fix_available': is_fix_available} printer.print_result(CliResult(success=True, message='Remediation fetched successfully', data=data)) diff --git a/cycode/cli/apps/auth/auth_command.py b/cycode/cli/apps/auth/auth_command.py index 8150be01..a402b0c2 100644 --- a/cycode/cli/apps/auth/auth_command.py +++ b/cycode/cli/apps/auth/auth_command.py @@ -4,13 +4,13 @@ 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.printers import ConsolePrinter from cycode.cli.utils.sentry import add_breadcrumb def auth_command(ctx: typer.Context) -> None: """Authenticates your machine.""" add_breadcrumb('auth') + printer = ctx.obj.get('console_printer') if ctx.invoked_subcommand is not None: # if it is a subcommand, do nothing @@ -23,6 +23,6 @@ def auth_command(ctx: typer.Context) -> None: auth_manager.authenticate() result = CliResult(success=True, message='Successfully logged into cycode') - ConsolePrinter(ctx).print_result(result) + printer.print_result(result) except Exception as err: handle_auth_exception(ctx, err) diff --git a/cycode/cli/apps/auth/auth_common.py b/cycode/cli/apps/auth/auth_common.py index fffee388..f6120d94 100644 --- a/cycode/cli/apps/auth/auth_common.py +++ b/cycode/cli/apps/auth/auth_common.py @@ -4,13 +4,14 @@ from cycode.cli.apps.auth.models import AuthInfo from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError, RequestHttpError -from cycode.cli.printers import ConsolePrinter 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.cycode_token_based_client import CycodeTokenBasedClient def get_authorization_info(ctx: Optional[typer.Context] = None) -> Optional[AuthInfo]: + printer = ctx.obj.get('console_printer') + client_id, client_secret = CredentialsManager().get_credentials() if not client_id or not client_secret: return None @@ -24,6 +25,6 @@ def get_authorization_info(ctx: Optional[typer.Context] = None) -> Optional[Auth return AuthInfo(user_id=user_id, tenant_id=tenant_id) except (RequestHttpError, HttpUnauthorizedError): if ctx: - ConsolePrinter(ctx).print_exception() + printer.print_exception() return None diff --git a/cycode/cli/apps/auth/check_command.py b/cycode/cli/apps/auth/check_command.py index cfa57f1c..0a5ea5b3 100644 --- a/cycode/cli/apps/auth/check_command.py +++ b/cycode/cli/apps/auth/check_command.py @@ -2,7 +2,6 @@ from cycode.cli.apps.auth.auth_common import get_authorization_info from cycode.cli.models import CliResult -from cycode.cli.printers import ConsolePrinter from cycode.cli.utils.sentry import add_breadcrumb @@ -10,7 +9,7 @@ def check_command(ctx: typer.Context) -> None: """Checks that your machine is associating the CLI with your Cycode account.""" add_breadcrumb('check') - printer = ConsolePrinter(ctx) + printer = ctx.obj.get('console_printer') auth_info = get_authorization_info(ctx) if auth_info is None: printer.print_result(CliResult(success=False, message='Cycode authentication failed')) diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index 67185dce..a3cae6b5 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -28,7 +28,6 @@ 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.models import CliError, Document, DocumentDetections, LocalScanResult -from cycode.cli.printers import ConsolePrinter from cycode.cli.utils import scan_utils from cycode.cli.utils.git_proxy import git_proxy from cycode.cli.utils.path_utils import get_path_by_os @@ -304,10 +303,11 @@ def scan_documents( ) -> None: scan_type = ctx.obj['scan_type'] progress_bar = ctx.obj['progress_bar'] + printer = ctx.obj.get('console_printer') if not documents_to_scan: progress_bar.stop() - ConsolePrinter(ctx).print_error( + printer.print_error( CliError( code='no_relevant_files', message='Error: The scan could not be completed - relevant files to scan are not found. ' @@ -569,7 +569,7 @@ def print_debug_scan_details(scan_details_response: 'ScanDetailsResponse') -> No def print_results( ctx: typer.Context, local_scan_results: List[LocalScanResult], errors: Optional[Dict[str, 'CliError']] = None ) -> None: - printer = ConsolePrinter(ctx) + printer = ctx.obj.get('console_printer') printer.print_scan_results(local_scan_results, errors) diff --git a/cycode/cli/cli_types.py b/cycode/cli/cli_types.py index 2a576ace..b8d4b8ee 100644 --- a/cycode/cli/cli_types.py +++ b/cycode/cli/cli_types.py @@ -10,6 +10,12 @@ class OutputTypeOption(str, Enum): TABLE = 'table' +class ExportTypeOption(str, Enum): + JSON = 'json' + HTML = 'html' + SVG = 'svg' + + class ScanTypeOption(str, Enum): SECRET = consts.SECRET_SCAN_TYPE SCA = consts.SCA_SCAN_TYPE diff --git a/cycode/cli/exceptions/handle_errors.py b/cycode/cli/exceptions/handle_errors.py index b9cb9c80..8d230902 100644 --- a/cycode/cli/exceptions/handle_errors.py +++ b/cycode/cli/exceptions/handle_errors.py @@ -4,14 +4,14 @@ import typer from cycode.cli.models import CliError, CliErrors -from cycode.cli.printers import ConsolePrinter from cycode.cli.utils.sentry import capture_exception def handle_errors( ctx: typer.Context, err: BaseException, cli_errors: CliErrors, *, return_exception: bool = False ) -> Optional['CliError']: - ConsolePrinter(ctx).print_exception(err) + printer = ctx.obj.get('console_printer') + printer.print_exception(err) if type(err) in cli_errors: error = cli_errors[type(err)].enrich(additional_message=str(err)) @@ -22,7 +22,7 @@ def handle_errors( if return_exception: return error - ConsolePrinter(ctx).print_error(error) + printer.print_error(error) return None if isinstance(err, click.ClickException): @@ -34,5 +34,5 @@ def handle_errors( if return_exception: return unknown_error - ConsolePrinter(ctx).print_error(unknown_error) + printer.print_error(unknown_error) raise typer.Exit(1) diff --git a/cycode/cli/printers/console_printer.py b/cycode/cli/printers/console_printer.py index 5ad5dac2..1f6af3ab 100644 --- a/cycode/cli/printers/console_printer.py +++ b/cycode/cli/printers/console_printer.py @@ -1,7 +1,12 @@ +import io from typing import TYPE_CHECKING, ClassVar, Dict, List, Optional, Type import typer +from rich.console import Console +from cycode.cli import consts +from cycode.cli.cli_types import ExportTypeOption +from cycode.cli.console import console, console_err from cycode.cli.exceptions.custom_exceptions import CycodeError from cycode.cli.models import CliError, CliResult from cycode.cli.printers.json_printer import JsonPrinter @@ -27,57 +32,115 @@ class ConsolePrinter: 'rich_sca': ScaTablePrinter, } - def __init__(self, ctx: typer.Context) -> None: + def __init__( + self, + ctx: typer.Context, + console_override: Optional['Console'] = None, + console_err_override: Optional['Console'] = None, + output_type_override: Optional[str] = None, + ) -> None: self.ctx = ctx + self.console = console_override or console + self.console_err = console_err_override or console_err self.scan_type = self.ctx.obj.get('scan_type') - self.output_type = self.ctx.obj.get('output') + self.output_type = output_type_override or self.ctx.obj.get('output') self.aggregation_report_url = self.ctx.obj.get('aggregation_report_url') - self._printer_class = self._AVAILABLE_PRINTERS.get(self.output_type) - if self._printer_class is None: - raise CycodeError(f'"{self.output_type}" output type is not supported.') + self.printer = self._get_scan_printer() - def print_scan_results( - self, - local_scan_results: List['LocalScanResult'], - errors: Optional[Dict[str, 'CliError']] = None, - ) -> None: - printer = self._get_scan_printer() - printer.print_scan_results(local_scan_results, errors) + self.console_record = None + + self.export_type = self.ctx.obj.get('export_type') + self.export_file = self.ctx.obj.get('export_file') + if console_override is None and self.export_type and self.export_file: + self.console_record = ConsolePrinter( + ctx, + console_override=Console(record=True, file=io.StringIO()), + console_err_override=Console(stderr=True, record=True, file=io.StringIO()), + output_type_override='json' if self.export_type == 'json' else self.output_type, + ) def _get_scan_printer(self) -> 'PrinterBase': - printer_class = self._printer_class + printer_class = self._AVAILABLE_PRINTERS.get(self.output_type) composite_printer = self._AVAILABLE_PRINTERS.get(f'{self.output_type}_{self.scan_type}') if composite_printer: printer_class = composite_printer - return printer_class(self.ctx) + if not printer_class: + raise CycodeError(f'"{self.output_type}" output type is not supported.') + + return printer_class(self.ctx, self.console, self.console_err) + + def print_scan_results( + self, + local_scan_results: List['LocalScanResult'], + errors: Optional[Dict[str, 'CliError']] = None, + ) -> None: + if self.console_record: + self.console_record.print_scan_results(local_scan_results, errors) + self.printer.print_scan_results(local_scan_results, errors) def print_result(self, result: CliResult) -> None: - self._printer_class(self.ctx).print_result(result) + if self.console_record: + self.console_record.print_result(result) + self.printer.print_result(result) def print_error(self, error: CliError) -> None: - self._printer_class(self.ctx).print_error(error) + if self.console_record: + self.console_record.print_error(error) + self.printer.print_error(error) def print_exception(self, e: Optional[BaseException] = None, force_print: bool = False) -> None: """Print traceback message in stderr if verbose mode is set.""" if force_print or self.ctx.obj.get('verbose', False): - self._printer_class(self.ctx).print_exception(e) + if self.console_record: + self.console_record.print_exception(e) + self.printer.print_exception(e) + + def export(self) -> None: + if self.console_record is None: + raise CycodeError('Console recording was not enabled. Cannot export.') + + if not self.export_file.suffix: + # resolve file extension based on the export type if not provided in the file name + self.export_file = self.export_file.with_suffix(f'.{self.export_type.lower()}') + + if self.export_type is ExportTypeOption.HTML: + self.console_record.console.save_html(self.export_file) + elif self.export_type is ExportTypeOption.SVG: + self.console_record.console.save_svg(self.export_file, title=consts.APP_NAME) + elif self.export_type is ExportTypeOption.JSON: + with open(self.export_file, 'w', encoding='UTF-8') as f: + self.console_record.console.file.seek(0) + f.write(self.console_record.console.file.read()) + else: + raise CycodeError(f'Export type "{self.export_type}" is not supported.') + + export_format_msg = f'{self.export_type.upper()} format' + if self.export_type in {ExportTypeOption.HTML, ExportTypeOption.SVG}: + export_format_msg += f' with {self.output_type.upper()} output type' + + clickable_path = f'[link=file://{self.export_file}]{self.export_file}[/link]' + self.console.print(f'[b green]Cycode CLI output exported to {clickable_path} in {export_format_msg}[/]') + + @property + def is_recording(self) -> bool: + return self.console_record is not None @property def is_json_printer(self) -> bool: - return self._printer_class == JsonPrinter + return isinstance(self.printer, JsonPrinter) @property def is_table_printer(self) -> bool: - return self._printer_class == TablePrinter + return isinstance(self.printer, TablePrinter) @property def is_text_printer(self) -> bool: - return self._printer_class == TextPrinter + return isinstance(self.printer, TextPrinter) @property def is_rich_printer(self) -> bool: - return self._printer_class == RichPrinter + return isinstance(self.printer, RichPrinter) diff --git a/cycode/cli/printers/json_printer.py b/cycode/cli/printers/json_printer.py index 76b1f7c7..6ad14e22 100644 --- a/cycode/cli/printers/json_printer.py +++ b/cycode/cli/printers/json_printer.py @@ -1,7 +1,6 @@ import json from typing import TYPE_CHECKING, Dict, List, Optional -from cycode.cli.console import console from cycode.cli.models import CliError, CliResult from cycode.cli.printers.printer_base import PrinterBase from cycode.cyclient.models import DetectionSchema @@ -14,12 +13,12 @@ class JsonPrinter(PrinterBase): def print_result(self, result: CliResult) -> None: result = {'result': result.success, 'message': result.message, 'data': result.data} - console.print_json(self.get_data_json(result)) + self.console.print_json(self.get_data_json(result)) def print_error(self, error: CliError) -> None: result = {'error': error.code, 'message': error.message} - console.print_json(self.get_data_json(result)) + self.console.print_json(self.get_data_json(result)) def print_scan_results( self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None @@ -46,7 +45,7 @@ def print_scan_results( # FIXME(MarshalX): we don't care about scan IDs in JSON output due to clumsy JSON root structure inlined_errors = [err._asdict() for err in errors.values()] - console.print_json(self._get_json_scan_result(scan_ids, detections_dict, report_urls, inlined_errors)) + self.console.print_json(self._get_json_scan_result(scan_ids, detections_dict, report_urls, inlined_errors)) def _get_json_scan_result( self, scan_ids: List[str], detections: dict, report_urls: List[str], errors: List[dict] diff --git a/cycode/cli/printers/printer_base.py b/cycode/cli/printers/printer_base.py index 9a86c3d6..f461a446 100644 --- a/cycode/cli/printers/printer_base.py +++ b/cycode/cli/printers/printer_base.py @@ -4,11 +4,12 @@ import typer -from cycode.cli.console import console_err from cycode.cli.models import CliError, CliResult from cycode.cyclient.headers import get_correlation_id if TYPE_CHECKING: + from rich.console import Console + from cycode.cli.models import LocalScanResult @@ -24,8 +25,15 @@ class PrinterBase(ABC): 'Please note that not all results may be available:[/]' ) - def __init__(self, ctx: typer.Context) -> None: + def __init__( + self, + ctx: typer.Context, + console: 'Console', + console_err: 'Console', + ) -> None: self.ctx = ctx + self.console = console + self.console_err = console_err @abstractmethod def print_scan_results( @@ -41,8 +49,7 @@ def print_result(self, result: CliResult) -> None: def print_error(self, error: CliError) -> None: pass - @staticmethod - def print_exception(e: Optional[BaseException] = None) -> None: + def print_exception(self, e: Optional[BaseException] = None) -> None: """We are printing it in stderr so, we don't care about supporting JSON and TABLE outputs. Note: @@ -54,6 +61,6 @@ def print_exception(e: Optional[BaseException] = None) -> None: else RichTraceback.from_exception(*sys.exc_info()) ) rich_traceback.show_locals = False - console_err.print(rich_traceback) + self.console_err.print(rich_traceback) - console_err.print(f'[red]Correlation ID:[/] {get_correlation_id()}') + self.console_err.print(f'[red]Correlation ID:[/] {get_correlation_id()}') diff --git a/cycode/cli/printers/rich_printer.py b/cycode/cli/printers/rich_printer.py index 61cb14ef..6693351a 100644 --- a/cycode/cli/printers/rich_printer.py +++ b/cycode/cli/printers/rich_printer.py @@ -8,7 +8,6 @@ from cycode.cli import consts from cycode.cli.cli_types import SeverityOption -from cycode.cli.console import console from cycode.cli.printers.text_printer import TextPrinter from cycode.cli.printers.utils.code_snippet_syntax import get_code_snippet_syntax from cycode.cli.printers.utils.detection_data import get_detection_title @@ -24,7 +23,7 @@ def print_scan_results( self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None ) -> None: if not errors and all(result.issue_detected == 0 for result in local_scan_results): - console.print(self.NO_DETECTIONS_MESSAGE) + self.console.print(self.NO_DETECTIONS_MESSAGE) return current_file = None @@ -44,14 +43,13 @@ def print_scan_results( self.print_report_urls_and_errors(local_scan_results, errors) - @staticmethod - def _print_file_header(file_path: str) -> None: + def _print_file_header(self, file_path: str) -> None: clickable_path = f'[link=file://{file_path}]{file_path}[/link]' file_header = Panel( Text.from_markup(f'[b purple3]:file_folder: File: {clickable_path}[/]', justify='center'), border_style='dim', ) - console.print(file_header) + self.console.print(file_header) def _get_details_table(self, detection: 'Detection') -> Table: details_table = Table(show_header=False, box=None, padding=(0, 1)) @@ -138,4 +136,4 @@ def _print_violation_card( title_align='center', ) - console.print(violation_card_panel) + self.console.print(violation_card_panel) diff --git a/cycode/cli/printers/tables/sca_table_printer.py b/cycode/cli/printers/tables/sca_table_printer.py index 70965be2..e334209c 100644 --- a/cycode/cli/printers/tables/sca_table_printer.py +++ b/cycode/cli/printers/tables/sca_table_printer.py @@ -2,7 +2,6 @@ from typing import TYPE_CHECKING, Dict, List from cycode.cli.cli_types import SeverityOption -from cycode.cli.console import console from cycode.cli.consts import LICENSE_COMPLIANCE_POLICY_ID, PACKAGE_VULNERABILITY_POLICY_ID from cycode.cli.models import Detection from cycode.cli.printers.tables.table import Table @@ -129,9 +128,8 @@ def _enrich_table_with_values(policy_id: str, table: Table, detection: Detection table.add_cell(CVE_COLUMNS, detection_details.get('vulnerability_id')) table.add_cell(LICENSE_COLUMN, detection_details.get('license')) - @staticmethod - def _print_summary_issues(detections_count: int, title: str) -> None: - console.print(f':no_entry: Found {detections_count} issues of type: [b]{title}[/]') + def _print_summary_issues(self, detections_count: int, title: str) -> None: + self.console.print(f':no_entry: Found {detections_count} issues of type: [b]{title}[/]') @staticmethod def _extract_detections_per_policy_id( diff --git a/cycode/cli/printers/tables/table_printer_base.py b/cycode/cli/printers/tables/table_printer_base.py index 73ab7f88..f36e489d 100644 --- a/cycode/cli/printers/tables/table_printer_base.py +++ b/cycode/cli/printers/tables/table_printer_base.py @@ -3,7 +3,6 @@ import typer -from cycode.cli.console import console from cycode.cli.models import CliError, CliResult from cycode.cli.printers.printer_base import PrinterBase from cycode.cli.printers.text_printer import TextPrinter @@ -14,8 +13,8 @@ class TablePrinterBase(PrinterBase, abc.ABC): - def __init__(self, ctx: typer.Context) -> None: - super().__init__(ctx) + def __init__(self, ctx: typer.Context, *args, **kwargs) -> None: + super().__init__(ctx, *args, **kwargs) self.scan_type: str = ctx.obj.get('scan_type') self.show_secret: bool = ctx.obj.get('show_secret', False) @@ -29,7 +28,7 @@ def print_scan_results( self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None ) -> None: if not errors and all(result.issue_detected == 0 for result in local_scan_results): - console.print(self.NO_DETECTIONS_MESSAGE) + self.console.print(self.NO_DETECTIONS_MESSAGE) return self._print_results(local_scan_results) @@ -37,9 +36,9 @@ def print_scan_results( if not errors: return - console.print(self.FAILED_SCAN_MESSAGE) + self.console.print(self.FAILED_SCAN_MESSAGE) for scan_id, error in errors.items(): - console.print(f'- {scan_id}: ', end='') + self.console.print(f'- {scan_id}: ', end='') self.print_error(error) def _is_git_repository(self) -> bool: @@ -49,13 +48,12 @@ def _is_git_repository(self) -> bool: def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: raise NotImplementedError - @staticmethod - def _print_table(table: 'Table') -> None: + def _print_table(self, table: 'Table') -> None: if table.get_rows(): - console.print(table.get_table()) + self.console.print(table.get_table()) - @staticmethod def _print_report_urls( + self, local_scan_results: List['LocalScanResult'], aggregation_report_url: Optional[str] = None, ) -> None: @@ -63,9 +61,9 @@ def _print_report_urls( if not report_urls and not aggregation_report_url: return if aggregation_report_url: - console.print(f'Report URL: {aggregation_report_url}') + self.console.print(f'Report URL: {aggregation_report_url}') return - console.print('Report URLs:') + self.console.print('Report URLs:') for report_url in report_urls: - console.print(f'- {report_url}') + self.console.print(f'- {report_url}') diff --git a/cycode/cli/printers/text_printer.py b/cycode/cli/printers/text_printer.py index b1eeb38e..f4dcf19a 100644 --- a/cycode/cli/printers/text_printer.py +++ b/cycode/cli/printers/text_printer.py @@ -5,7 +5,6 @@ from rich.markup import escape from cycode.cli.cli_types import SeverityOption -from cycode.cli.console import console from cycode.cli.models import CliError, CliResult, Document from cycode.cli.printers.printer_base import PrinterBase from cycode.cli.printers.utils.code_snippet_syntax import get_code_snippet_syntax @@ -17,8 +16,8 @@ class TextPrinter(PrinterBase): - def __init__(self, ctx: typer.Context) -> None: - super().__init__(ctx) + def __init__(self, ctx: typer.Context, *args, **kwargs) -> None: + super().__init__(ctx, *args, **kwargs) self.scan_type = ctx.obj.get('scan_type') self.command_scan_type: str = ctx.info_name self.show_secret: bool = ctx.obj.get('show_secret', False) @@ -28,23 +27,23 @@ def print_result(self, result: CliResult) -> None: if not result.success: color = 'red' - console.print(result.message, style=color) + self.console.print(result.message, style=color) if not result.data: return - console.print('\nAdditional data:', style=color) + self.console.print('\nAdditional data:', style=color) for name, value in result.data.items(): - console.print(f'- {name}: {value}', style=color) + self.console.print(f'- {name}: {value}', style=color) def print_error(self, error: CliError) -> None: - console.print(f'[red]Error: {error.message}[/]', highlight=False) + self.console.print(f'[red]Error: {error.message}[/]', highlight=False) def print_scan_results( self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None ) -> None: if not errors and all(result.issue_detected == 0 for result in local_scan_results): - console.print(self.NO_DETECTIONS_MESSAGE) + self.console.print(self.NO_DETECTIONS_MESSAGE) return detections, _ = sort_and_group_detections_from_scan_result(local_scan_results) @@ -58,9 +57,8 @@ def __print_document_detection(self, document: 'Document', detection: 'Detection self.__print_detection_code_segment(detection, document) self._print_new_line() - @staticmethod - def _print_new_line() -> None: - console.line() + def _print_new_line(self) -> None: + self.console.line() def __print_detection_summary(self, detection: 'Detection', document_path: str) -> None: title = get_detection_title(self.scan_type, detection) @@ -74,14 +72,14 @@ def __print_detection_summary(self, detection: 'Detection', document_path: str) detection_commit_id = detection.detection_details.get('commit_id') detection_commit_id_message = f'\nCommit SHA: {detection_commit_id}' if detection_commit_id else '' - console.print( + self.console.print( f'{severity_icon}', severity, f'violation: [b bright_red]{title}[/]{detection_commit_id_message}\n{clickable_document_path}:', ) def __print_detection_code_segment(self, detection: 'Detection', document: Document) -> None: - console.print( + self.console.print( get_code_snippet_syntax( self.scan_type, self.command_scan_type, @@ -100,19 +98,18 @@ def print_report_urls_and_errors( if not errors: return - console.print(self.FAILED_SCAN_MESSAGE) + self.console.print(self.FAILED_SCAN_MESSAGE) for scan_id, error in errors.items(): - console.print(f'- {scan_id}: ', end='') + self.console.print(f'- {scan_id}: ', end='') self.print_error(error) - @staticmethod - def print_report_urls(report_urls: List[str], aggregation_report_url: Optional[str] = None) -> None: + def print_report_urls(self, report_urls: List[str], aggregation_report_url: Optional[str] = None) -> None: if not report_urls and not aggregation_report_url: return if aggregation_report_url: - console.print(f'Report URL: {aggregation_report_url}') + self.console.print(f'Report URL: {aggregation_report_url}') return - console.print('Report URLs:') + self.console.print('Report URLs:') for report_url in report_urls: - console.print(f'- {report_url}') + self.console.print(f'- {report_url}') diff --git a/tests/cli/exceptions/test_handle_scan_errors.py b/tests/cli/exceptions/test_handle_scan_errors.py index a6b2a9ec..abd297db 100644 --- a/tests/cli/exceptions/test_handle_scan_errors.py +++ b/tests/cli/exceptions/test_handle_scan_errors.py @@ -10,6 +10,7 @@ from cycode.cli.console import console_err from cycode.cli.exceptions import custom_exceptions from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception +from cycode.cli.printers import ConsolePrinter from cycode.cli.utils.git_proxy import git_proxy if TYPE_CHECKING: @@ -18,7 +19,9 @@ @pytest.fixture() def ctx() -> typer.Context: - return typer.Context(click.Command('path'), obj={'verbose': False, 'output': OutputTypeOption.TEXT}) + ctx = typer.Context(click.Command('path'), obj={'verbose': False, 'output': OutputTypeOption.TEXT}) + ctx.obj['console_printer'] = ConsolePrinter(ctx) + return ctx @pytest.mark.parametrize( @@ -60,6 +63,7 @@ def test_handle_exception_click_error(ctx: typer.Context) -> None: def test_handle_exception_verbose(monkeypatch: 'MonkeyPatch') -> None: ctx = typer.Context(click.Command('path'), obj={'verbose': True, 'output': OutputTypeOption.TEXT}) + ctx.obj['console_printer'] = ConsolePrinter(ctx) error_text = 'test' From a1c7a4f8266e97018178aa4eb50f672758d461f3 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 23 Apr 2025 16:58:50 +0200 Subject: [PATCH 160/257] CM-47505 - Add support for Unity Version Control (formerly Plastic SCM) (#298) --- cycode/cli/commands/scan/code_scanner.py | 91 ++++++++++++++++++++++++ cycode/cli/consts.py | 4 ++ 2 files changed, 95 insertions(+) diff --git a/cycode/cli/commands/scan/code_scanner.py b/cycode/cli/commands/scan/code_scanner.py index 478b8ffe..4091118f 100644 --- a/cycode/cli/commands/scan/code_scanner.py +++ b/cycode/cli/commands/scan/code_scanner.py @@ -32,6 +32,7 @@ from cycode.cli.utils.progress_bar import ScanProgressBarSection from cycode.cli.utils.scan_batch import run_parallel_batched_scan from cycode.cli.utils.scan_utils import set_issue_detected +from cycode.cli.utils.shell_executor import shell from cycode.cyclient import logger from cycode.cyclient.config import set_logging_level from cycode.cyclient.models import Detection, DetectionSchema, DetectionsPerFile, ZippedFileScanResult @@ -666,6 +667,9 @@ def get_scan_parameters(context: click.Context, paths: Optional[Tuple[str]] = No return scan_parameters remote_url = try_get_git_remote_url(paths[0]) + if not remote_url: + remote_url = try_to_get_plastic_remote_url(paths[0]) + if remote_url: # TODO(MarshalX): remove hardcode in context context.obj['remote_url'] = remote_url @@ -684,6 +688,93 @@ def try_get_git_remote_url(path: str) -> Optional[str]: return None +def _get_plastic_repository_name(path: str) -> Optional[str]: + """Gets the name of the Plastic repository from the current working directory. + + The command to execute is: + cm status --header --machinereadable --fieldseparator=":::" + + Example of status header in machine-readable format: + STATUS:::0:::Project/RepoName:::OrgName@ServerInfo + """ + + try: + command = [ + 'cm', + 'status', + '--header', + '--machinereadable', + f'--fieldseparator={consts.PLASTIC_VCS_DATA_SEPARATOR}', + ] + + status = shell(command=command, timeout=consts.PLASTIC_VSC_CLI_TIMEOUT, working_directory=path) + if not status: + logger.debug('Failed to get Plastic repository name (command failed)') + return None + + status_parts = status.split(consts.PLASTIC_VCS_DATA_SEPARATOR) + if len(status_parts) < 2: + logger.debug('Failed to parse Plastic repository name (command returned unexpected format)') + return None + + return status_parts[2].strip() + except Exception as e: + logger.debug('Failed to get Plastic repository name', exc_info=e) + return None + + +def _get_plastic_repository_list(working_dir: Optional[str] = None) -> Dict[str, str]: + """Gets the list of Plastic repositories and their GUIDs. + + The command to execute is: + cm repo list --format="{repname}:::{repguid}" + + Example line with data: + Project/RepoName:::tapo1zqt-wn99-4752-h61m-7d9k79d40r4v + + Each line represents an individual repository. + """ + + repo_name_to_guid = {} + + try: + command = ['cm', 'repo', 'ls', f'--format={{repname}}{consts.PLASTIC_VCS_DATA_SEPARATOR}{{repguid}}'] + + status = shell(command=command, timeout=consts.PLASTIC_VSC_CLI_TIMEOUT, working_directory=working_dir) + if not status: + logger.debug('Failed to get Plastic repository list (command failed)') + return repo_name_to_guid + + status_lines = status.splitlines() + for line in status_lines: + data_parts = line.split(consts.PLASTIC_VCS_DATA_SEPARATOR) + if len(data_parts) < 2: + logger.debug('Failed to parse Plastic repository list line (unexpected format), %s', {'line': line}) + continue + + repo_name, repo_guid = data_parts + repo_name_to_guid[repo_name.strip()] = repo_guid.strip() + + return repo_name_to_guid + except Exception as e: + logger.debug('Failed to get Plastic repository list', exc_info=e) + return repo_name_to_guid + + +def try_to_get_plastic_remote_url(path: str) -> Optional[str]: + repository_name = _get_plastic_repository_name(path) + if not repository_name: + return None + + repository_map = _get_plastic_repository_list(path) + if repository_name not in repository_map: + logger.debug('Failed to get Plastic repository GUID (repository not found in the list)') + return None + + repository_guid = repository_map[repository_name] + return f'{consts.PLASTIC_VCS_REMOTE_URI_PREFIX}{repository_guid}' + + def exclude_irrelevant_detections( detections: List[Detection], scan_type: str, command_scan_type: str, severity_threshold: str ) -> List[Detection]: diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 42bd1ab7..003218d6 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -230,3 +230,7 @@ SCA_SKIP_RESTORE_DEPENDENCIES_FLAG = 'no-restore' SCA_GRADLE_ALL_SUB_PROJECTS_FLAG = 'gradle-all-sub-projects' + +PLASTIC_VCS_DATA_SEPARATOR = ':::' +PLASTIC_VSC_CLI_TIMEOUT = 10 +PLASTIC_VCS_REMOTE_URI_PREFIX = 'plastic::' From 4681d3268140494f10e16a701739a188b2ba513a Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Fri, 25 Apr 2025 14:10:33 +0200 Subject: [PATCH 161/257] CM-47698 - Fix Windows signing of CLI executable (#300) --- .github/workflows/build_executable.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 44c9a02a..41cfa2ed 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -166,6 +166,7 @@ jobs: shell: cmd env: SM_HOST: ${{ secrets.SM_HOST }} + SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }} SM_API_KEY: ${{ secrets.SM_API_KEY }} SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }} SM_CODE_SIGNING_CERT_SHA1_HASH: ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }} @@ -174,7 +175,7 @@ jobs: curl -X GET https://one.digicert.com/signingmanager/api-ui/v1/releases/smtools-windows-x64.msi/download -H "x-api-key:%SM_API_KEY%" -o smtools-windows-x64.msi msiexec /i smtools-windows-x64.msi /quiet /qn C:\Windows\System32\certutil.exe -csp "DigiCert Signing Manager KSP" -key -user - smksp_cert_sync.exe + smctl windows certsync --keypair-alias=%SM_KEYPAIR_ALIAS% :: sign executable signtool.exe sign /sha1 %SM_CODE_SIGNING_CERT_SHA1_HASH% /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 ".\dist\cycode-cli.exe" From 99b820bad90c78b31b284eba2b2b8c8483cd0473 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Fri, 25 Apr 2025 14:23:08 +0200 Subject: [PATCH 162/257] CM-47699 - Fix Linux executable builds (#301) --- .github/workflows/build_executable.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 41cfa2ed..c9154d7d 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -15,10 +15,10 @@ jobs: strategy: fail-fast: false matrix: - os: [ ubuntu-20.04, macos-13, macos-14, windows-2019 ] + os: [ ubuntu-22.04, macos-13, macos-14, windows-2019 ] mode: [ 'onefile', 'onedir' ] exclude: - - os: ubuntu-20.04 + - os: ubuntu-22.04 mode: onedir - os: windows-2019 mode: onedir @@ -31,7 +31,7 @@ jobs: steps: - name: Run Cimon - if: matrix.os == 'ubuntu-20.04' + if: matrix.os == 'ubuntu-22.04' uses: cycodelabs/cimon-action@v0 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} From 0405c6b8729667f1b279dde7df6260d0a79884fc Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Fri, 25 Apr 2025 14:25:25 +0200 Subject: [PATCH 163/257] CM-47493 - Make changes in CLI v3.0.0 after feedback (#299) --- cycode/cli/app.py | 8 ++ cycode/cli/apps/ai_remediation/__init__.py | 2 +- cycode/cli/apps/auth/__init__.py | 1 + cycode/cli/apps/configure/__init__.py | 2 +- cycode/cli/apps/ignore/__init__.py | 2 +- cycode/cli/apps/report/__init__.py | 2 +- cycode/cli/apps/scan/__init__.py | 2 +- cycode/cli/apps/status/__init__.py | 2 +- cycode/cli/cli_types.py | 4 +- .../sca/npm/restore_npm_dependencies.py | 3 +- .../files_collector/sca/sca_code_scanner.py | 5 +- cycode/cli/printers/console_printer.py | 17 +++-- cycode/cli/printers/printer_base.py | 45 +++++++++++ cycode/cli/printers/rich_printer.py | 73 +++++++++++------- .../cli/printers/tables/sca_table_printer.py | 3 +- cycode/cli/printers/tables/table_printer.py | 1 + .../cli/printers/tables/table_printer_base.py | 11 +-- cycode/cli/printers/text_printer.py | 22 ++---- .../cli/printers/utils/code_snippet_syntax.py | 6 +- cycode/cli/printers/utils/detection_data.py | 76 ++++++++++++++++++- .../detection_ordering/common_ordering.py | 17 +---- 21 files changed, 214 insertions(+), 90 deletions(-) diff --git a/cycode/cli/app.py b/cycode/cli/app.py index 5c83be9e..507c03c8 100644 --- a/cycode/cli/app.py +++ b/cycode/cli/app.py @@ -3,6 +3,7 @@ from typing import Annotated, Optional import typer +from typer import rich_utils from typer.completion import install_callback, show_callback from cycode import __version__ @@ -18,11 +19,18 @@ from cycode.cyclient.models import UserAgentOptionScheme from cycode.logger import set_logging_level +# By default, it uses dim style which is hard to read with the combination of color from RICH_HELP +rich_utils.STYLE_ERRORS_SUGGESTION = 'bold' +# By default, it uses blue color which is too dark for some terminals +rich_utils.RICH_HELP = "Try [cyan]'{command_path} {help_option}'[/] for help." + + app = typer.Typer( pretty_exceptions_show_locals=False, pretty_exceptions_short=True, context_settings=CLI_CONTEXT_SETTINGS, rich_markup_mode='rich', + no_args_is_help=True, add_completion=False, # we add it manually to control the rich help panel ) diff --git a/cycode/cli/apps/ai_remediation/__init__.py b/cycode/cli/apps/ai_remediation/__init__.py index 6b5a3013..0f017cf7 100644 --- a/cycode/cli/apps/ai_remediation/__init__.py +++ b/cycode/cli/apps/ai_remediation/__init__.py @@ -2,7 +2,7 @@ from cycode.cli.apps.ai_remediation.ai_remediation_command import ai_remediation_command -app = typer.Typer() +app = typer.Typer(no_args_is_help=True) app.command(name='ai-remediation', short_help='Get AI remediation (INTERNAL).', hidden=True)(ai_remediation_command) # backward compatibility diff --git a/cycode/cli/apps/auth/__init__.py b/cycode/cli/apps/auth/__init__.py index 82e71fbc..951a9f1f 100644 --- a/cycode/cli/apps/auth/__init__.py +++ b/cycode/cli/apps/auth/__init__.py @@ -6,6 +6,7 @@ app = typer.Typer( name='auth', help='Authenticate your machine to associate the CLI with your Cycode account.', + no_args_is_help=True, ) app.callback(invoke_without_command=True)(auth_command) app.command(name='check')(check_command) diff --git a/cycode/cli/apps/configure/__init__.py b/cycode/cli/apps/configure/__init__.py index 815874d1..039c6f2e 100644 --- a/cycode/cli/apps/configure/__init__.py +++ b/cycode/cli/apps/configure/__init__.py @@ -2,7 +2,7 @@ from cycode.cli.apps.configure.configure_command import configure_command -app = typer.Typer() +app = typer.Typer(no_args_is_help=True) app.command(name='configure', short_help='Initial command to configure your CLI client authentication.')( configure_command ) diff --git a/cycode/cli/apps/ignore/__init__.py b/cycode/cli/apps/ignore/__init__.py index 3c51d38a..e6573b69 100644 --- a/cycode/cli/apps/ignore/__init__.py +++ b/cycode/cli/apps/ignore/__init__.py @@ -2,5 +2,5 @@ from cycode.cli.apps.ignore.ignore_command import ignore_command -app = typer.Typer() +app = typer.Typer(no_args_is_help=True) app.command(name='ignore', short_help='Ignores a specific value, path or rule ID.')(ignore_command) diff --git a/cycode/cli/apps/report/__init__.py b/cycode/cli/apps/report/__init__.py index f71532c8..40cc696a 100644 --- a/cycode/cli/apps/report/__init__.py +++ b/cycode/cli/apps/report/__init__.py @@ -3,6 +3,6 @@ from cycode.cli.apps.report import sbom from cycode.cli.apps.report.report_command import report_command -app = typer.Typer(name='report') +app = typer.Typer(name='report', no_args_is_help=True) app.callback(short_help='Generate report. You`ll need to specify which report type to perform.')(report_command) app.add_typer(sbom.app) diff --git a/cycode/cli/apps/scan/__init__.py b/cycode/cli/apps/scan/__init__.py index 07c15978..136e7bef 100644 --- a/cycode/cli/apps/scan/__init__.py +++ b/cycode/cli/apps/scan/__init__.py @@ -7,7 +7,7 @@ from cycode.cli.apps.scan.repository.repository_command import repository_command from cycode.cli.apps.scan.scan_command import scan_command, scan_command_result_callback -app = typer.Typer(name='scan') +app = typer.Typer(name='scan', no_args_is_help=True) app.callback( short_help='Scan the content for Secrets, IaC, SCA, and SAST violations.', diff --git a/cycode/cli/apps/status/__init__.py b/cycode/cli/apps/status/__init__.py index f01e3b30..1161b2e6 100644 --- a/cycode/cli/apps/status/__init__.py +++ b/cycode/cli/apps/status/__init__.py @@ -3,6 +3,6 @@ from cycode.cli.apps.status.status_command import status_command from cycode.cli.apps.status.version_command import version_command -app = typer.Typer() +app = typer.Typer(no_args_is_help=True) app.command(name='status', short_help='Show the CLI status and exit.')(status_command) app.command(name='version', hidden=True, short_help='Alias to status command.')(version_command) diff --git a/cycode/cli/cli_types.py b/cycode/cli/cli_types.py index b8d4b8ee..9b792a01 100644 --- a/cycode/cli/cli_types.py +++ b/cycode/cli/cli_types.py @@ -94,6 +94,6 @@ def __rich__(self) -> str: SeverityOption.INFO.value: ':blue_circle:', SeverityOption.LOW.value: ':yellow_circle:', SeverityOption.MEDIUM.value: ':orange_circle:', - SeverityOption.HIGH.value: ':heavy_large_circle:', - SeverityOption.CRITICAL.value: ':red_circle:', + SeverityOption.HIGH.value: ':red_circle:', + SeverityOption.CRITICAL.value: ':exclamation_mark:', # double_exclamation_mark is not red } diff --git a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py index 68175d88..672ee0db 100644 --- a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py +++ b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py @@ -37,5 +37,6 @@ def get_lock_file_name(self) -> str: def verify_restore_file_already_exist(self, restore_file_path: str) -> bool: return os.path.isfile(restore_file_path) - def prepare_manifest_file_path_for_command(self, manifest_file_path: str) -> str: + @staticmethod + def prepare_manifest_file_path_for_command(manifest_file_path: str) -> str: return manifest_file_path.replace(os.sep + NPM_MANIFEST_FILE_NAME, '') diff --git a/cycode/cli/files_collector/sca/sca_code_scanner.py b/cycode/cli/files_collector/sca/sca_code_scanner.py index fc8c3809..88626c9c 100644 --- a/cycode/cli/files_collector/sca/sca_code_scanner.py +++ b/cycode/cli/files_collector/sca/sca_code_scanner.py @@ -124,14 +124,15 @@ def try_restore_dependencies( def add_dependencies_tree_document( ctx: typer.Context, documents_to_scan: List[Document], is_git_diff: bool = False ) -> None: - documents_to_add: Dict[str, Document] = {} + documents_to_add: Dict[str, Document] = {document.path: document for document in documents_to_scan} restore_dependencies_list = restore_handlers(ctx, is_git_diff) for restore_dependencies in restore_dependencies_list: for document in documents_to_scan: try_restore_dependencies(ctx, documents_to_add, restore_dependencies, document) - documents_to_scan.extend(list(documents_to_add.values())) + # mutate original list using slice assignment + documents_to_scan[:] = list(documents_to_add.values()) def restore_handlers(ctx: typer.Context, is_git_diff: bool) -> List[BaseRestoreDependencies]: diff --git a/cycode/cli/printers/console_printer.py b/cycode/cli/printers/console_printer.py index 1f6af3ab..00eb38cf 100644 --- a/cycode/cli/printers/console_printer.py +++ b/cycode/cli/printers/console_printer.py @@ -29,7 +29,6 @@ class ConsolePrinter: # overrides 'table_sca': ScaTablePrinter, 'text_sca': ScaTablePrinter, - 'rich_sca': ScaTablePrinter, } def __init__( @@ -42,12 +41,7 @@ def __init__( self.ctx = ctx self.console = console_override or console self.console_err = console_err_override or console_err - - self.scan_type = self.ctx.obj.get('scan_type') self.output_type = output_type_override or self.ctx.obj.get('output') - self.aggregation_report_url = self.ctx.obj.get('aggregation_report_url') - - self.printer = self._get_scan_printer() self.console_record = None @@ -61,7 +55,16 @@ def __init__( output_type_override='json' if self.export_type == 'json' else self.output_type, ) - def _get_scan_printer(self) -> 'PrinterBase': + @property + def scan_type(self) -> str: + return self.ctx.obj.get('scan_type') + + @property + def aggregation_report_url(self) -> str: + return self.ctx.obj.get('aggregation_report_url') + + @property + def printer(self) -> 'PrinterBase': printer_class = self._AVAILABLE_PRINTERS.get(self.output_type) composite_printer = self._AVAILABLE_PRINTERS.get(f'{self.output_type}_{self.scan_type}') diff --git a/cycode/cli/printers/printer_base.py b/cycode/cli/printers/printer_base.py index f461a446..23ba7384 100644 --- a/cycode/cli/printers/printer_base.py +++ b/cycode/cli/printers/printer_base.py @@ -1,9 +1,11 @@ import sys from abc import ABC, abstractmethod +from collections import defaultdict from typing import TYPE_CHECKING, Dict, List, Optional import typer +from cycode.cli.cli_types import SeverityOption from cycode.cli.models import CliError, CliResult from cycode.cyclient.headers import get_correlation_id @@ -35,6 +37,18 @@ def __init__( self.console = console self.console_err = console_err + @property + def scan_type(self) -> str: + return self.ctx.obj.get('scan_type') + + @property + def command_scan_type(self) -> str: + return self.ctx.info_name + + @property + def show_secret(self) -> bool: + return self.ctx.obj.get('show_secret', False) + @abstractmethod def print_scan_results( self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None @@ -64,3 +78,34 @@ def print_exception(self, e: Optional[BaseException] = None) -> None: self.console_err.print(rich_traceback) self.console_err.print(f'[red]Correlation ID:[/] {get_correlation_id()}') + + def print_scan_results_summary(self, local_scan_results: List['LocalScanResult']) -> None: + """Print a summary of scan results based on severity levels. + + Args: + local_scan_results (List['LocalScanResult']): A list of local scan results containing detections. + + The summary includes the count of detections for each severity level + and is displayed in the console in a formatted string. + """ + + detections_count = 0 + severity_counts = defaultdict(int) + for local_scan_result in local_scan_results: + for document_detections in local_scan_result.document_detections: + for detection in document_detections.detections: + if detection.severity: + detections_count += 1 + severity_counts[SeverityOption(detection.severity)] += 1 + + self.console.print(f'[bold]Cycode found {detections_count} violations[/]', end=': ') + + # Example of string: CRITICAL - 6 | HIGH - 0 | MEDIUM - 14 | LOW - 0 | INFO - 0 + for index, severity in enumerate(reversed(SeverityOption), start=1): + end = ' | ' + if index == len(SeverityOption): + end = '\n' + + self.console.print( + SeverityOption.get_member_emoji(severity), severity, '-', severity_counts[severity], end=end + ) diff --git a/cycode/cli/printers/rich_printer.py b/cycode/cli/printers/rich_printer.py index 6693351a..3401b8f5 100644 --- a/cycode/cli/printers/rich_printer.py +++ b/cycode/cli/printers/rich_printer.py @@ -1,4 +1,3 @@ -from pathlib import Path from typing import TYPE_CHECKING, Dict, List, Optional from rich.console import Group @@ -10,7 +9,11 @@ from cycode.cli.cli_types import SeverityOption from cycode.cli.printers.text_printer import TextPrinter from cycode.cli.printers.utils.code_snippet_syntax import get_code_snippet_syntax -from cycode.cli.printers.utils.detection_data import get_detection_title +from cycode.cli.printers.utils.detection_data import ( + get_detection_clickable_cwe_cve, + get_detection_file_path, + get_detection_title, +) from cycode.cli.printers.utils.detection_ordering.common_ordering import sort_and_group_detections_from_scan_result from cycode.cli.printers.utils.rich_helpers import get_columns_in_1_to_3_ratio, get_markdown_panel, get_panel @@ -19,6 +22,8 @@ class RichPrinter(TextPrinter): + MAX_PATH_LENGTH = 60 + def print_scan_results( self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None ) -> None: @@ -26,14 +31,9 @@ def print_scan_results( self.console.print(self.NO_DETECTIONS_MESSAGE) return - current_file = None detections, _ = sort_and_group_detections_from_scan_result(local_scan_results) detections_count = len(detections) for detection_number, (detection, document) in enumerate(detections, start=1): - if current_file != document.path: - current_file = document.path - self._print_file_header(current_file) - self._print_violation_card( document, detection, @@ -41,16 +41,9 @@ def print_scan_results( detections_count, ) + self.print_scan_results_summary(local_scan_results) self.print_report_urls_and_errors(local_scan_results, errors) - def _print_file_header(self, file_path: str) -> None: - clickable_path = f'[link=file://{file_path}]{file_path}[/link]' - file_header = Panel( - Text.from_markup(f'[b purple3]:file_folder: File: {clickable_path}[/]', justify='center'), - border_style='dim', - ) - self.console.print(file_header) - def _get_details_table(self, detection: 'Detection') -> Table: details_table = Table(show_header=False, box=None, padding=(0, 1)) @@ -62,15 +55,32 @@ def _get_details_table(self, detection: 'Detection') -> Table: details_table.add_row('Severity', f'{severity_icon} {SeverityOption(severity).__rich__()}') detection_details = detection.detection_details - path = Path(detection_details.get('file_name', '')) - details_table.add_row('In file', path.name) # it is name already except for IaC :) - # we do not allow using rich output with SCA; SCA designed to be used with table output - if self.scan_type == consts.IAC_SCAN_TYPE: - details_table.add_row('IaC Provider', detection_details.get('infra_provider')) - elif self.scan_type == consts.SECRET_SCAN_TYPE: + path = str(get_detection_file_path(self.scan_type, detection)) + shorten_path = f'...{path[-self.MAX_PATH_LENGTH:]}' if len(path) > self.MAX_PATH_LENGTH else path + details_table.add_row('In file', f'[link=file://{path}]{shorten_path}[/]') + + if self.scan_type == consts.SECRET_SCAN_TYPE: details_table.add_row('Secret SHA', detection_details.get('sha512')) + elif self.scan_type == consts.SCA_SCAN_TYPE: + details_table.add_row('CVEs', get_detection_clickable_cwe_cve(self.scan_type, detection)) + details_table.add_row('Package', detection_details.get('package_name')) + details_table.add_row('Version', detection_details.get('package_version')) + + is_package_vulnerability = 'alert' in detection_details + if is_package_vulnerability: + details_table.add_row( + 'First patched version', detection_details['alert'].get('first_patched_version', 'Not fixed') + ) + + details_table.add_row('Dependency path', detection_details.get('dependency_paths', 'N/A')) + + if not is_package_vulnerability: + details_table.add_row('License', detection_details.get('license')) + elif self.scan_type == consts.IAC_SCAN_TYPE: + details_table.add_row('IaC Provider', detection_details.get('infra_provider')) elif self.scan_type == consts.SAST_SCAN_TYPE: + details_table.add_row('CWE', get_detection_clickable_cwe_cve(self.scan_type, detection)) details_table.add_row('Subcategory', detection_details.get('category')) details_table.add_row('Language', ', '.join(detection_details.get('languages', []))) @@ -105,12 +115,17 @@ def _print_violation_card( title=':computer: Code Snippet', ) - guidelines_panel = None - guidelines = detection.detection_details.get('remediation_guidelines') - if guidelines: - guidelines_panel = get_markdown_panel( - guidelines, - title=':clipboard: Cycode Guidelines', + is_sca_package_vulnerability = self.scan_type == consts.SCA_SCAN_TYPE and 'alert' in detection.detection_details + if is_sca_package_vulnerability: + summary = detection.detection_details['alert'].get('description') + else: + summary = detection.detection_details.get('description') or detection.message + + summary_panel = None + if summary: + summary_panel = get_markdown_panel( + summary, + title=':memo: Summary', ) custom_guidelines_panel = None @@ -124,8 +139,8 @@ def _print_violation_card( navigation = Text(f'Violation {detection_number} of {detections_count}', style='dim', justify='right') renderables = [navigation, get_columns_in_1_to_3_ratio(details_panel, code_snippet_panel)] - if guidelines_panel: - renderables.append(guidelines_panel) + if summary_panel: + renderables.append(summary_panel) if custom_guidelines_panel: renderables.append(custom_guidelines_panel) diff --git a/cycode/cli/printers/tables/sca_table_printer.py b/cycode/cli/printers/tables/sca_table_printer.py index e334209c..74ac2832 100644 --- a/cycode/cli/printers/tables/sca_table_printer.py +++ b/cycode/cli/printers/tables/sca_table_printer.py @@ -45,6 +45,7 @@ def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: self._print_summary_issues(len(detections), self._get_title(policy_id)) self._print_table(table) + self.print_scan_results_summary(local_scan_results) self._print_report_urls(local_scan_results, aggregation_report_url) @staticmethod @@ -129,7 +130,7 @@ def _enrich_table_with_values(policy_id: str, table: Table, detection: Detection table.add_cell(LICENSE_COLUMN, detection_details.get('license')) def _print_summary_issues(self, detections_count: int, title: str) -> None: - self.console.print(f':no_entry: Found {detections_count} issues of type: [b]{title}[/]') + self.console.print(f'[bold]Cycode found {detections_count} violations of type: [cyan]{title}[/]') @staticmethod def _extract_detections_per_policy_id( diff --git a/cycode/cli/printers/tables/table_printer.py b/cycode/cli/printers/tables/table_printer.py index e36b1b01..4f821c7f 100644 --- a/cycode/cli/printers/tables/table_printer.py +++ b/cycode/cli/printers/tables/table_printer.py @@ -37,6 +37,7 @@ def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: table.set_group_separator_indexes(group_separator_indexes) self._print_table(table) + self.print_scan_results_summary(local_scan_results) self._print_report_urls(local_scan_results, self.ctx.obj.get('aggregation_report_url')) def _get_table(self) -> Table: diff --git a/cycode/cli/printers/tables/table_printer_base.py b/cycode/cli/printers/tables/table_printer_base.py index f36e489d..5d2aaa73 100644 --- a/cycode/cli/printers/tables/table_printer_base.py +++ b/cycode/cli/printers/tables/table_printer_base.py @@ -1,8 +1,6 @@ import abc from typing import TYPE_CHECKING, Dict, List, Optional -import typer - from cycode.cli.models import CliError, CliResult from cycode.cli.printers.printer_base import PrinterBase from cycode.cli.printers.text_printer import TextPrinter @@ -13,16 +11,11 @@ class TablePrinterBase(PrinterBase, abc.ABC): - def __init__(self, ctx: typer.Context, *args, **kwargs) -> None: - super().__init__(ctx, *args, **kwargs) - self.scan_type: str = ctx.obj.get('scan_type') - self.show_secret: bool = ctx.obj.get('show_secret', False) - def print_result(self, result: CliResult) -> None: - TextPrinter(self.ctx).print_result(result) + TextPrinter(self.ctx, self.console, self.console_err).print_result(result) def print_error(self, error: CliError) -> None: - TextPrinter(self.ctx).print_error(error) + TextPrinter(self.ctx, self.console, self.console_err).print_error(error) def print_scan_results( self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None diff --git a/cycode/cli/printers/text_printer.py b/cycode/cli/printers/text_printer.py index f4dcf19a..6eb4b78b 100644 --- a/cycode/cli/printers/text_printer.py +++ b/cycode/cli/printers/text_printer.py @@ -1,13 +1,9 @@ -import urllib.parse from typing import TYPE_CHECKING, Dict, List, Optional -import typer -from rich.markup import escape - from cycode.cli.cli_types import SeverityOption from cycode.cli.models import CliError, CliResult, Document from cycode.cli.printers.printer_base import PrinterBase -from cycode.cli.printers.utils.code_snippet_syntax import get_code_snippet_syntax +from cycode.cli.printers.utils.code_snippet_syntax import get_code_snippet_syntax, get_detection_line from cycode.cli.printers.utils.detection_data import get_detection_title from cycode.cli.printers.utils.detection_ordering.common_ordering import sort_and_group_detections_from_scan_result @@ -16,12 +12,6 @@ class TextPrinter(PrinterBase): - def __init__(self, ctx: typer.Context, *args, **kwargs) -> None: - super().__init__(ctx, *args, **kwargs) - self.scan_type = ctx.obj.get('scan_type') - self.command_scan_type: str = ctx.info_name - self.show_secret: bool = ctx.obj.get('show_secret', False) - def print_result(self, result: CliResult) -> None: color = 'default' if not result.success: @@ -50,6 +40,7 @@ def print_scan_results( for detection, document in detections: self.__print_document_detection(document, detection) + self.print_scan_results_summary(local_scan_results) self.print_report_urls_and_errors(local_scan_results, errors) def __print_document_detection(self, document: 'Document', detection: 'Detection') -> None: @@ -66,16 +57,17 @@ def __print_detection_summary(self, detection: 'Detection', document_path: str) severity = SeverityOption(detection.severity) if detection.severity else 'N/A' severity_icon = SeverityOption.get_member_emoji(detection.severity) if detection.severity else '' - escaped_document_path = escape(urllib.parse.quote(document_path)) - clickable_document_path = f'[link file://{escaped_document_path}]{document_path}' + line_no = get_detection_line(self.scan_type, detection) + 1 + clickable_document_path = f'[u]{document_path}:{line_no}[/]' detection_commit_id = detection.detection_details.get('commit_id') detection_commit_id_message = f'\nCommit SHA: {detection_commit_id}' if detection_commit_id else '' self.console.print( - f'{severity_icon}', + severity_icon, severity, - f'violation: [b bright_red]{title}[/]{detection_commit_id_message}\n{clickable_document_path}:', + f'violation: [b bright_red]{title}[/]{detection_commit_id_message}\n' + f'[dodger_blue1]File: {clickable_document_path}[/]', ) def __print_detection_code_segment(self, detection: 'Detection', document: Document) -> None: diff --git a/cycode/cli/printers/utils/code_snippet_syntax.py b/cycode/cli/printers/utils/code_snippet_syntax.py index c3c9f59b..aae33872 100644 --- a/cycode/cli/printers/utils/code_snippet_syntax.py +++ b/cycode/cli/printers/utils/code_snippet_syntax.py @@ -17,7 +17,7 @@ def _get_code_segment_start_line(detection_line: int, lines_to_display: int) -> return 0 if start_line < 0 else start_line -def _get_detection_line(scan_type: str, detection: 'Detection') -> int: +def get_detection_line(scan_type: str, detection: 'Detection') -> int: return ( detection.detection_details.get('line', -1) if scan_type == consts.SECRET_SCAN_TYPE @@ -29,7 +29,7 @@ def _get_code_snippet_syntax_from_file( scan_type: str, detection: 'Detection', document: 'Document', lines_to_display: int, obfuscate: bool ) -> Syntax: detection_details = detection.detection_details - detection_line = _get_detection_line(scan_type, detection) + detection_line = get_detection_line(scan_type, detection) start_line_index = _get_code_segment_start_line(detection_line, lines_to_display) detection_position = get_position_in_line(document.content, detection_details.get('start_position', -1)) violation_length = detection_details.get('length', -1) @@ -69,7 +69,7 @@ def _get_code_snippet_syntax_from_git_diff( scan_type: str, detection: 'Detection', document: 'Document', obfuscate: bool ) -> Syntax: detection_details = detection.detection_details - detection_line = _get_detection_line(scan_type, detection) + detection_line = get_detection_line(scan_type, detection) detection_position = detection_details.get('start_position', -1) violation_length = detection_details.get('length', -1) diff --git a/cycode/cli/printers/utils/detection_data.py b/cycode/cli/printers/utils/detection_data.py index 66171226..358b4c63 100644 --- a/cycode/cli/printers/utils/detection_data.py +++ b/cycode/cli/printers/utils/detection_data.py @@ -1,4 +1,5 @@ -from typing import TYPE_CHECKING +from pathlib import Path +from typing import TYPE_CHECKING, Optional from cycode.cli import consts @@ -6,6 +7,63 @@ from cycode.cyclient.models import Detection +def get_cwe_cve_link(cwe_cve: Optional[str]) -> Optional[str]: + if not cwe_cve: + return None + + if cwe_cve.startswith('GHSA'): + return f'https://github.com/advisories/{cwe_cve}' + + if cwe_cve.startswith('CWE'): + # string example: 'CWE-532: Insertion of Sensitive Information into Log File' + parts = cwe_cve.split('-') + if len(parts) < 1: + return None + + number = '' + for char in parts[1]: + if char.isdigit(): + number += char + else: + break + + return f'https://cwe.mitre.org/data/definitions/{number}' + + if cwe_cve.startswith('CVE'): + return f'https://cve.mitre.org/cgi-bin/cvename.cgi?name={cwe_cve}' + + return None + + +def get_detection_clickable_cwe_cve(scan_type: str, detection: 'Detection') -> str: + def link(url: str, name: str) -> str: + return f'[link={url}]{name}[/]' + + if scan_type == consts.SCA_SCAN_TYPE: + cve = detection.detection_details.get('vulnerability_id') + return link(get_cwe_cve_link(cve), cve) if cve else '' + if scan_type == consts.SAST_SCAN_TYPE: + renderables = [] + for cwe in detection.detection_details.get('cwe', []): + cwe and renderables.append(link(get_cwe_cve_link(cwe), cwe)) + return ', '.join(renderables) + + return '' + + +def get_detection_cwe_cve(scan_type: str, detection: 'Detection') -> Optional[str]: + if scan_type == consts.SCA_SCAN_TYPE: + return detection.detection_details.get('vulnerability_id') + if scan_type == consts.SAST_SCAN_TYPE: + cwes = detection.detection_details.get('cwe') # actually it is List[str] + if not cwes: + return None + + return ' | '.join(cwes) + + return None + + def get_detection_title(scan_type: str, detection: 'Detection') -> str: title = detection.message if scan_type == consts.SAST_SCAN_TYPE: @@ -13,4 +71,18 @@ def get_detection_title(scan_type: str, detection: 'Detection') -> str: elif scan_type == consts.SECRET_SCAN_TYPE: title = f'Hardcoded {detection.type} is used' - return title + is_sca_package_vulnerability = scan_type == consts.SCA_SCAN_TYPE and 'alert' in detection.detection_details + if is_sca_package_vulnerability: + title = detection.detection_details['alert'].get('summary', 'N/A') + + cwe_cve = get_detection_cwe_cve(scan_type, detection) + return f'[{cwe_cve}] {title}' if cwe_cve else title + + +def get_detection_file_path(scan_type: str, detection: 'Detection') -> Path: + if scan_type == consts.SECRET_SCAN_TYPE: + folder_path = detection.detection_details.get('file_path', '') + file_name = detection.detection_details.get('file_name', '') + return Path.joinpath(Path(folder_path), Path(file_name)) + + return Path(detection.detection_details.get('file_name', '')) diff --git a/cycode/cli/printers/utils/detection_ordering/common_ordering.py b/cycode/cli/printers/utils/detection_ordering/common_ordering.py index 531cbc4c..d93b858e 100644 --- a/cycode/cli/printers/utils/detection_ordering/common_ordering.py +++ b/cycode/cli/printers/utils/detection_ordering/common_ordering.py @@ -1,4 +1,3 @@ -from collections import defaultdict from typing import TYPE_CHECKING, List, Set, Tuple from cycode.cli.cli_types import SeverityOption @@ -37,22 +36,14 @@ def _sort_detections_by_file_path( def sort_and_group_detections( detections_with_documents: List[Tuple['Detection', 'Document']], ) -> GroupedDetections: - """Sort detections by severity and group by file name.""" - detections = [] + """Sort detections by severity. We do not have groping here (don't find the best one yet).""" group_separator_indexes = set() # we sort detections by file path to make persist output order - sorted_detections = _sort_detections_by_file_path(detections_with_documents) + sorted_by_path_detections = _sort_detections_by_file_path(detections_with_documents) + sorted_by_severity = _sort_detections_by_severity(sorted_by_path_detections) - grouped_by_file_path = defaultdict(list) - for detection, document in sorted_detections: - grouped_by_file_path[document.path].append((detection, document)) - - for file_path_group in grouped_by_file_path.values(): - group_separator_indexes.add(len(detections) - 1) # indexing starts from 0 - detections.extend(_sort_detections_by_severity(file_path_group)) - - return detections, group_separator_indexes + return sorted_by_severity, group_separator_indexes def sort_and_group_detections_from_scan_result(local_scan_results: List['LocalScanResult']) -> GroupedDetections: From 070763648dbb34e9c8b297379958833e4b84854c Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 29 Apr 2025 12:10:52 +0200 Subject: [PATCH 164/257] CM-45588 - Improve `--help` (#302) --- .github/workflows/build_executable.yml | 3 +- .pre-commit-hooks.yaml | 4 +- CONTRIBUTING.md | 4 +- README.md | 20 +- cycode/cli/app.py | 10 + cycode/cli/apps/ai_remediation/__init__.py | 15 +- .../ai_remediation/ai_remediation_command.py | 9 +- cycode/cli/apps/auth/__init__.py | 20 +- cycode/cli/apps/auth/auth_command.py | 13 +- cycode/cli/apps/auth/auth_common.py | 9 +- cycode/cli/apps/auth/auth_manager.py | 4 +- cycode/cli/apps/auth/check_command.py | 24 -- cycode/cli/apps/configure/__init__.py | 17 +- .../cli/apps/configure/configure_command.py | 15 +- cycode/cli/apps/ignore/ignore_command.py | 17 +- cycode/cli/apps/report/report_command.py | 6 +- cycode/cli/apps/scan/__init__.py | 11 +- cycode/cli/apps/scan/code_scanner.py | 162 ++++++-- .../commit_history/commit_history_command.py | 6 +- cycode/cli/apps/scan/path/path_command.py | 4 +- .../scan/pre_commit/pre_commit_command.py | 4 +- .../scan/pre_receive/pre_receive_command.py | 4 +- .../scan/repository/repository_command.py | 2 +- cycode/cli/apps/scan/scan_command.py | 25 +- cycode/cli/apps/status/get_cli_status.py | 8 +- cycode/cli/apps/status/models.py | 3 +- cycode/cli/apps/status/status_command.py | 18 +- cycode/cli/config.py | 2 +- cycode/cli/consts.py | 10 +- cycode/cli/exceptions/custom_exceptions.py | 6 +- cycode/cli/exceptions/handle_scan_errors.py | 5 +- cycode/cli/files_collector/excluder.py | 8 +- .../iac/tf_content_generator.py | 7 +- .../files_collector/models/in_memory_zip.py | 2 +- cycode/cli/files_collector/path_documents.py | 18 +- .../files_collector/repository_documents.py | 9 +- .../sca/base_restore_dependencies.py | 6 +- .../sca/go/restore_go_dependencies.py | 4 +- .../sca/maven/restore_gradle_dependencies.py | 10 +- .../sca/maven/restore_maven_dependencies.py | 6 +- .../sca/npm/restore_npm_dependencies.py | 3 +- .../sca/nuget/restore_nuget_dependencies.py | 3 +- .../sca/ruby/restore_ruby_dependencies.py | 4 +- .../sca/sbt/restore_sbt_dependencies.py | 4 +- .../files_collector/sca/sca_code_scanner.py | 30 +- cycode/cli/files_collector/walk_ignore.py | 6 +- cycode/cli/files_collector/zip_documents.py | 4 +- cycode/cli/models.py | 18 +- cycode/cli/printers/console_printer.py | 8 +- cycode/cli/printers/json_printer.py | 6 +- cycode/cli/printers/printer_base.py | 9 +- cycode/cli/printers/rich_printer.py | 6 +- .../cli/printers/tables/sca_table_printer.py | 18 +- cycode/cli/printers/tables/table.py | 16 +- cycode/cli/printers/tables/table_models.py | 6 +- cycode/cli/printers/tables/table_printer.py | 4 +- .../cli/printers/tables/table_printer_base.py | 8 +- cycode/cli/printers/text_printer.py | 8 +- .../detection_ordering/common_ordering.py | 22 +- .../utils/detection_ordering/sca_ordering.py | 13 +- cycode/cli/user_settings/base_file_manager.py | 7 +- .../cli/user_settings/config_file_manager.py | 11 +- .../user_settings/configuration_manager.py | 10 +- .../cli/user_settings/credentials_manager.py | 10 +- cycode/cli/utils/enum_utils.py | 3 +- cycode/cli/utils/get_api_client.py | 4 +- cycode/cli/utils/git_proxy.py | 20 +- cycode/cli/utils/ignore_utils.py | 30 +- cycode/cli/utils/jwt_utils.py | 4 +- cycode/cli/utils/path_utils.py | 12 +- cycode/cli/utils/progress_bar.py | 10 +- cycode/cli/utils/scan_batch.py | 16 +- cycode/cli/utils/shell_executor.py | 4 +- cycode/cli/utils/task_timer.py | 12 +- cycode/cli/utils/version_checker.py | 16 +- cycode/cli/utils/yaml_utils.py | 15 +- cycode/cyclient/cycode_client_base.py | 12 +- cycode/cyclient/cycode_dev_based_client.py | 4 +- cycode/cyclient/headers.py | 1 + cycode/cyclient/models.py | 52 +-- cycode/cyclient/report_client.py | 4 +- cycode/cyclient/scan_client.py | 10 +- cycode/logger.py | 4 +- poetry.lock | 377 +++++++++--------- process_executable_file.py | 10 +- pyproject.toml | 20 +- .../commands/version/test_version_checker.py | 2 +- .../cli/exceptions/test_handle_scan_errors.py | 4 +- tests/cli/files_collector/test_walk_ignore.py | 4 +- tests/cyclient/test_auth_client.py | 4 +- tests/cyclient/test_scan_client.py | 11 +- tests/test_performance_get_all_files.py | 14 +- .../test_configuration_manager.py | 3 +- tests/utils/test_ignore_utils.py | 6 +- 94 files changed, 819 insertions(+), 633 deletions(-) delete mode 100644 cycode/cli/apps/auth/check_command.py diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 44c9a02a..41cfa2ed 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -166,6 +166,7 @@ jobs: shell: cmd env: SM_HOST: ${{ secrets.SM_HOST }} + SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }} SM_API_KEY: ${{ secrets.SM_API_KEY }} SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }} SM_CODE_SIGNING_CERT_SHA1_HASH: ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }} @@ -174,7 +175,7 @@ jobs: curl -X GET https://one.digicert.com/signingmanager/api-ui/v1/releases/smtools-windows-x64.msi/download -H "x-api-key:%SM_API_KEY%" -o smtools-windows-x64.msi msiexec /i smtools-windows-x64.msi /quiet /qn C:\Windows\System32\certutil.exe -csp "DigiCert Signing Manager KSP" -key -user - smksp_cert_sync.exe + smctl windows certsync --keypair-alias=%SM_KEYPAIR_ALIAS% :: sign executable signtool.exe sign /sha1 %SM_CODE_SIGNING_CERT_SHA1_HASH% /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 ".\dist\cycode-cli.exe" diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 40e7a614..02a86db0 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -3,10 +3,10 @@ language: python language_version: python3 entry: cycode - args: [ '--no-progress-meter', 'scan', '--scan-type', 'secret', 'pre_commit' ] + args: [ '--no-progress-meter', 'scan', '--scan-type', 'secret', 'pre-commit' ] - id: cycode-sca name: Cycode SCA pre-commit defender language: python language_version: python3 entry: cycode - args: [ '--no-progress-meter', 'scan', '--scan-type', 'sca', 'pre_commit' ] + args: [ '--no-progress-meter', 'scan', '--scan-type', 'sca', 'pre-commit' ] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 75b8e85f..857a27cd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ But it’s fine to use a higher version without using new features from these ve The project is under Poetry project management. To deal with it, you should install it on your system: -Install Poetry (feel free to use Brew, etc): +Install Poetry (feel free to use Brew, etc.): ```shell curl -sSL https://install.python-poetry.org | python - -y @@ -70,6 +70,8 @@ poetry run ruff format . Many rules support auto-fixing. You can run it with the `--fix` flag. +Plugin for JB IDEs with auto formatting on save is available [here](https://plugins.jetbrains.com/plugin/20574-ruff). + ### Branching and versioning We use the `main` branch as the main one. diff --git a/README.md b/README.md index 6218cba8..fbe5c6a6 100644 --- a/README.md +++ b/README.md @@ -281,7 +281,7 @@ The following are the options and commands available with the Cycode CLI applica | [auth](#using-the-auth-command) | Authenticate your machine to associate the CLI with your Cycode account. | | [configure](#using-the-configure-command) | Initial command to configure your CLI client authentication. | | [ignore](#ignoring-scan-results) | Ignores a specific value, path or rule ID. | -| [scan](#running-a-scan) | Scan the content for Secrets/IaC/SCA/SAST violations. You`ll need to specify which scan type to perform: commit_history/path/repository/etc. | +| [scan](#running-a-scan) | Scan the content for Secrets/IaC/SCA/SAST violations. You`ll need to specify which scan type to perform: commit-history/path/repository/etc. | | [report](#report-command) | Generate report. You`ll need to specify which report type to perform. | | status | Show the CLI status and exit. | @@ -294,7 +294,7 @@ The Cycode CLI application offers several types of scans so that you can choose | Option | Description | |------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `-t, --scan-type [secret\|iac\|sca\|sast]` | Specify the scan you wish to execute (`secret`/`iac`/`sca`/`sast`), the default is `secret`. | -| `--secret TEXT` | Specify a Cycode client secret for this specific scan execution. | +| `--client-secret TEXT` | Specify a Cycode client secret for this specific scan execution. | | `--client-id TEXT` | Specify a Cycode client ID for this specific scan execution. | | `--show-secret BOOLEAN` | Show secrets in plain text. See [Show/Hide Secrets](#showhide-secrets) section for more details. | | `--soft-fail BOOLEAN` | Run scan without failing, always return a non-error status code. See [Soft Fail](#soft-fail) section for more details. | @@ -308,9 +308,9 @@ The Cycode CLI application offers several types of scans so that you can choose | Command | Description | |----------------------------------------|-----------------------------------------------------------------| -| [commit_history](#commit-history-scan) | Scan all the commits history in this git repository | +| [commit-history](#commit-history-scan) | Scan all the commits history in this git repository | | [path](#path-scan) | Scan the files in the path supplied in the command | -| [pre_commit](#pre-commit-scan) | Use this command to scan the content that was not committed yet | +| [pre-commit](#pre-commit-scan) | Use this command to scan the content that was not committed yet | | [repository](#repository-scan) | Scan git repository including its history | ### Options @@ -466,25 +466,25 @@ A commit history scan is limited to a local repository’s previous commits, foc To execute a commit history scan, execute the following: -`cycode scan commit_history {{path}}` +`cycode scan commit-history {{path}}` For example, consider a scenario in which you want to scan the commit history for a repository stored in `~/home/git/codebase`. You could then execute the following: -`cycode scan commit_history ~/home/git/codebase` +`cycode scan commit-history ~/home/git/codebase` The following options are available for use with this command: | Option | Description | |---------------------------|----------------------------------------------------------------------------------------------------------| -| `-r, --commit_range TEXT` | Scan a commit range in this git repository, by default cycode scans all commit history (example: HEAD~1) | +| `-r, --commit-range TEXT` | Scan a commit range in this git repository, by default cycode scans all commit history (example: HEAD~1) | #### Commit Range Option -The commit history scan, by default, examines the repository’s entire commit history, all the way back to the initial commit. You can instead limit the scan to a specific commit range by adding the argument `--commit_range` (`-r`) followed by the name you specify. +The commit history scan, by default, examines the repository’s entire commit history, all the way back to the initial commit. You can instead limit the scan to a specific commit range by adding the argument `--commit-range` (`-r`) followed by the name you specify. Consider the previous example. If you wanted to scan only specific commits in your repository, you could execute the following: -`cycode scan commit_history -r {{from-commit-id}}...{{to-commit-id}} ~/home/git/codebase` +`cycode scan commit-history -r {{from-commit-id}}...{{to-commit-id}} ~/home/git/codebase` ### Pre-Commit Scan @@ -823,7 +823,7 @@ The following commands are available for use with this command: | Command | Description | |------------------|-----------------------------------------------------------------| | `path` | Generate SBOM report for provided path in the command | -| `repository_url` | Generate SBOM report for provided repository URI in the command | +| `repository-url` | Generate SBOM report for provided repository URI in the command | ### Repository diff --git a/cycode/cli/app.py b/cycode/cli/app.py index 507c03c8..b07b3221 100644 --- a/cycode/cli/app.py +++ b/cycode/cli/app.py @@ -25,10 +25,19 @@ rich_utils.RICH_HELP = "Try [cyan]'{command_path} {help_option}'[/] for help." +_cycode_cli_docs = 'https://github.com/cycodehq/cycode-cli/blob/main/README.md' +_cycode_cli_epilog = f"""[bold]Documentation[/] + + + +For more details and advanced usage, visit: [link={_cycode_cli_docs}]{_cycode_cli_docs}[/link] +""" + app = typer.Typer( pretty_exceptions_show_locals=False, pretty_exceptions_short=True, context_settings=CLI_CONTEXT_SETTINGS, + epilog=_cycode_cli_epilog, rich_markup_mode='rich', no_args_is_help=True, add_completion=False, # we add it manually to control the rich help panel @@ -125,6 +134,7 @@ def app_callback( ), ] = False, ) -> None: + """[bold cyan]Cycode CLI - Command Line Interface for Cycode.[/]""" init_sentry() add_breadcrumb('cycode') diff --git a/cycode/cli/apps/ai_remediation/__init__.py b/cycode/cli/apps/ai_remediation/__init__.py index 0f017cf7..cd471a08 100644 --- a/cycode/cli/apps/ai_remediation/__init__.py +++ b/cycode/cli/apps/ai_remediation/__init__.py @@ -2,8 +2,19 @@ from cycode.cli.apps.ai_remediation.ai_remediation_command import ai_remediation_command -app = typer.Typer(no_args_is_help=True) -app.command(name='ai-remediation', short_help='Get AI remediation (INTERNAL).', hidden=True)(ai_remediation_command) +app = typer.Typer() + +_ai_remediation_epilog = """ +Note: AI remediation suggestions are generated automatically and should be reviewed before applying. +""" + +app.command( + name='ai-remediation', + short_help='Get AI remediation (INTERNAL).', + epilog=_ai_remediation_epilog, + hidden=True, + no_args_is_help=True, +)(ai_remediation_command) # backward compatibility app.command(hidden=True, name='ai_remediation')(ai_remediation_command) diff --git a/cycode/cli/apps/ai_remediation/ai_remediation_command.py b/cycode/cli/apps/ai_remediation/ai_remediation_command.py index 0a82b815..ea5ef826 100644 --- a/cycode/cli/apps/ai_remediation/ai_remediation_command.py +++ b/cycode/cli/apps/ai_remediation/ai_remediation_command.py @@ -16,7 +16,14 @@ def ai_remediation_command( bool, typer.Option('--fix', help='Apply fixes to resolve violations. Note: fix could be not available.') ] = False, ) -> None: - """Get AI remediation (INTERNAL).""" + """:robot: [bold cyan]Get AI-powered remediation for security issues.[/] + + This command provides AI-generated remediation guidance for detected security issues. + + Example usage: + * `cycode ai-remediation `: View remediation guidance + * `cycode ai-remediation --fix`: Apply suggested fixes + """ client = get_scan_cycode_client() try: diff --git a/cycode/cli/apps/auth/__init__.py b/cycode/cli/apps/auth/__init__.py index 951a9f1f..beecae38 100644 --- a/cycode/cli/apps/auth/__init__.py +++ b/cycode/cli/apps/auth/__init__.py @@ -1,12 +1,14 @@ import typer from cycode.cli.apps.auth.auth_command import auth_command -from cycode.cli.apps.auth.check_command import check_command - -app = typer.Typer( - name='auth', - help='Authenticate your machine to associate the CLI with your Cycode account.', - no_args_is_help=True, -) -app.callback(invoke_without_command=True)(auth_command) -app.command(name='check')(check_command) + +_auth_command_docs = 'https://github.com/cycodehq/cycode-cli/blob/main/README.md#using-the-auth-command' +_auth_command_epilog = f"""[bold]Documentation[/] + + + +For more details and advanced usage, visit: [link={_auth_command_docs}]{_auth_command_docs}[/link] +""" + +app = typer.Typer(no_args_is_help=False) +app.command(name='auth', epilog=_auth_command_epilog, short_help='Authenticate your machine with Cycode.')(auth_command) diff --git a/cycode/cli/apps/auth/auth_command.py b/cycode/cli/apps/auth/auth_command.py index a402b0c2..817e0213 100644 --- a/cycode/cli/apps/auth/auth_command.py +++ b/cycode/cli/apps/auth/auth_command.py @@ -8,14 +8,17 @@ def auth_command(ctx: typer.Context) -> None: - """Authenticates your machine.""" + """:key: [bold cyan]Authenticate your machine with Cycode.[/] + + This command handles authentication with Cycode's security platform. + + Example usage: + * `cycode auth`: Start interactive authentication + * `cycode auth --help`: View authentication options + """ add_breadcrumb('auth') printer = ctx.obj.get('console_printer') - if ctx.invoked_subcommand is not None: - # if it is a subcommand, do nothing - return - try: logger.debug('Starting authentication process') diff --git a/cycode/cli/apps/auth/auth_common.py b/cycode/cli/apps/auth/auth_common.py index f6120d94..52b7b6fa 100644 --- a/cycode/cli/apps/auth/auth_common.py +++ b/cycode/cli/apps/auth/auth_common.py @@ -1,6 +1,4 @@ -from typing import Optional - -import typer +from typing import TYPE_CHECKING, Optional from cycode.cli.apps.auth.models import AuthInfo from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError, RequestHttpError @@ -8,8 +6,11 @@ from cycode.cli.utils.jwt_utils import get_user_and_tenant_ids_from_access_token from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient +if TYPE_CHECKING: + from typer import Context + -def get_authorization_info(ctx: Optional[typer.Context] = None) -> Optional[AuthInfo]: +def get_authorization_info(ctx: 'Context') -> Optional[AuthInfo]: printer = ctx.obj.get('console_printer') client_id, client_secret = CredentialsManager().get_credentials() diff --git a/cycode/cli/apps/auth/auth_manager.py b/cycode/cli/apps/auth/auth_manager.py index 2652bfe1..56a480e4 100644 --- a/cycode/cli/apps/auth/auth_manager.py +++ b/cycode/cli/apps/auth/auth_manager.py @@ -1,6 +1,6 @@ import time import webbrowser -from typing import TYPE_CHECKING, Tuple +from typing import TYPE_CHECKING from cycode.cli.exceptions.custom_exceptions import AuthProcessError from cycode.cli.user_settings.configuration_manager import ConfigurationManager @@ -78,7 +78,7 @@ def get_api_token_polling(self, session_id: str, code_verifier: str) -> 'ApiToke def save_api_token(self, api_token: 'ApiToken') -> None: self.credentials_manager.update_credentials(api_token.client_id, api_token.secret) - def _generate_pkce_code_pair(self) -> Tuple[str, str]: + def _generate_pkce_code_pair(self) -> tuple[str, str]: code_verifier = generate_random_string(self.CODE_VERIFIER_LENGTH) code_challenge = hash_string_to_sha256(code_verifier) return code_challenge, code_verifier diff --git a/cycode/cli/apps/auth/check_command.py b/cycode/cli/apps/auth/check_command.py deleted file mode 100644 index 0a5ea5b3..00000000 --- a/cycode/cli/apps/auth/check_command.py +++ /dev/null @@ -1,24 +0,0 @@ -import typer - -from cycode.cli.apps.auth.auth_common import get_authorization_info -from cycode.cli.models import CliResult -from cycode.cli.utils.sentry import add_breadcrumb - - -def check_command(ctx: typer.Context) -> None: - """Checks that your machine is associating the CLI with your Cycode account.""" - add_breadcrumb('check') - - printer = ctx.obj.get('console_printer') - auth_info = get_authorization_info(ctx) - if auth_info is None: - printer.print_result(CliResult(success=False, message='Cycode authentication failed')) - return - - printer.print_result( - CliResult( - success=True, - message='Cycode authentication verified', - data={'user_id': auth_info.user_id, 'tenant_id': auth_info.tenant_id}, - ) - ) diff --git a/cycode/cli/apps/configure/__init__.py b/cycode/cli/apps/configure/__init__.py index 039c6f2e..ce73c450 100644 --- a/cycode/cli/apps/configure/__init__.py +++ b/cycode/cli/apps/configure/__init__.py @@ -2,7 +2,18 @@ from cycode.cli.apps.configure.configure_command import configure_command +_configure_command_docs = 'https://github.com/cycodehq/cycode-cli/blob/main/README.md#using-the-configure-command' +_configure_command_epilog = f"""[bold]Documentation[/] + + + +For more details and advanced usage, visit: [link={_configure_command_docs}]{_configure_command_docs}[/link] +""" + + app = typer.Typer(no_args_is_help=True) -app.command(name='configure', short_help='Initial command to configure your CLI client authentication.')( - configure_command -) +app.command( + name='configure', + epilog=_configure_command_epilog, + short_help='Initial command to configure your CLI client authentication.', +)(configure_command) diff --git a/cycode/cli/apps/configure/configure_command.py b/cycode/cli/apps/configure/configure_command.py index 2aa86a8f..348e3ccb 100644 --- a/cycode/cli/apps/configure/configure_command.py +++ b/cycode/cli/apps/configure/configure_command.py @@ -23,7 +23,20 @@ def _should_update_value( def configure_command() -> None: - """Configure your CLI client authentication manually.""" + """:gear: [bold cyan]Configure Cycode CLI settings.[/] + + This command allows you to configure various aspects of the Cycode CLI. + + Configuration options: + * API URL: The base URL for Cycode's API (for on-premise or EU installations) + * APP URL: The base URL for Cycode's web application (for on-premise or EU installations) + * Client ID: Your Cycode client ID for authentication + * Client Secret: Your Cycode client secret for authentication + + Example usage: + * `cycode configure`: Start interactive configuration + * `cycode configure --help`: View configuration options + """ add_breadcrumb('configure') global_config_manager = CONFIGURATION_MANAGER.global_config_file_manager diff --git a/cycode/cli/apps/ignore/ignore_command.py b/cycode/cli/apps/ignore/ignore_command.py index 079a3c2d..1183114a 100644 --- a/cycode/cli/apps/ignore/ignore_command.py +++ b/cycode/cli/apps/ignore/ignore_command.py @@ -83,7 +83,20 @@ def ignore_command( # noqa: C901 bool, typer.Option('--global', '-g', help='Add an ignore rule to the global CLI config.') ] = False, ) -> None: - """Ignores a specific value, path or rule ID.""" + """:no_entry: [bold cyan]Ignore specific findings or paths in scans.[/] + + This command allows you to exclude specific items from Cycode scans, including: + * Paths: Exclude specific files or directories + * Rules: Ignore specific security rules + * Values: Exclude specific sensitive values + * Packages: Ignore specific package versions + * CVEs: Exclude specific vulnerabilities + + Example usage: + * `cycode ignore --by-path .env`: Ignore the tests directory + * `cycode ignore --by-rule GUID`: Ignore rule with the specified GUID + * `cycode ignore --by-package lodash@4.17.21`: Ignore lodash version 4.17.21 + """ add_breadcrumb('ignore') all_by_values = [by_value, by_sha, by_path, by_rule, by_package, by_cve] @@ -145,4 +158,4 @@ def ignore_command( # noqa: C901 'exclusion_value': exclusion_value, }, ) - configuration_manager.add_exclusion(configuration_scope, scan_type, exclusion_type, exclusion_value) + configuration_manager.add_exclusion(configuration_scope, str(scan_type), exclusion_type, exclusion_value) diff --git a/cycode/cli/apps/report/report_command.py b/cycode/cli/apps/report/report_command.py index 91a061c3..75debb33 100644 --- a/cycode/cli/apps/report/report_command.py +++ b/cycode/cli/apps/report/report_command.py @@ -5,7 +5,11 @@ def report_command(ctx: typer.Context) -> int: - """Generate report.""" + """:bar_chart: [bold cyan]Generate security reports.[/] + + Example usage: + * `cycode report sbom`: Generate SBOM report + """ add_breadcrumb('report') ctx.obj['progress_bar'] = get_progress_bar(hidden=False, sections=SBOM_REPORT_PROGRESS_BAR_SECTIONS) return 1 diff --git a/cycode/cli/apps/scan/__init__.py b/cycode/cli/apps/scan/__init__.py index 136e7bef..ada2d105 100644 --- a/cycode/cli/apps/scan/__init__.py +++ b/cycode/cli/apps/scan/__init__.py @@ -9,14 +9,23 @@ app = typer.Typer(name='scan', no_args_is_help=True) +_scan_command_docs = 'https://github.com/cycodehq/cycode-cli/blob/main/README.md#scan-command' +_scan_command_epilog = f"""[bold]Documentation[/] + + + +For more details and advanced usage, visit: [link={_scan_command_docs}]{_scan_command_docs}[/link] +""" + app.callback( short_help='Scan the content for Secrets, IaC, SCA, and SAST violations.', result_callback=scan_command_result_callback, + epilog=_scan_command_epilog, )(scan_command) app.command(name='path', short_help='Scan the files in the paths provided in the command.')(path_command) app.command(name='repository', short_help='Scan the Git repository included files.')(repository_command) -app.command(name='commit-history', short_help='Scan all the commits history in this git repository.')( +app.command(name='commit-history', short_help='Scan all the commits history in this Git repository.')( commit_history_command ) app.command( diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index a3cae6b5..0209d9da 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -3,7 +3,7 @@ import sys import time from platform import platform -from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Callable, Optional from uuid import UUID, uuid4 import click @@ -34,6 +34,7 @@ from cycode.cli.utils.progress_bar import ScanProgressBarSection from cycode.cli.utils.scan_batch import run_parallel_batched_scan from cycode.cli.utils.scan_utils import set_issue_detected +from cycode.cli.utils.shell_executor import shell from cycode.cyclient.models import Detection, DetectionSchema, DetectionsPerFile, ZippedFileScanResult from cycode.logger import get_logger, set_logging_level @@ -83,7 +84,7 @@ def scan_sca_commit_range(ctx: typer.Context, path: str, commit_range: str) -> N scan_commit_range_documents(ctx, from_commit_documents, to_commit_documents, scan_parameters=scan_parameters) -def scan_disk_files(ctx: typer.Context, paths: Tuple[str]) -> None: +def scan_disk_files(ctx: typer.Context, paths: tuple[str, ...]) -> None: scan_type = ctx.obj['scan_type'] progress_bar = ctx.obj['progress_bar'] @@ -95,7 +96,7 @@ def scan_disk_files(ctx: typer.Context, paths: Tuple[str]) -> None: handle_scan_exception(ctx, e) -def set_issue_detected_by_scan_results(ctx: typer.Context, scan_results: List[LocalScanResult]) -> None: +def set_issue_detected_by_scan_results(ctx: typer.Context, scan_results: list[LocalScanResult]) -> None: set_issue_detected(ctx, any(scan_result.issue_detected for scan_result in scan_results)) @@ -109,6 +110,7 @@ def _should_use_sync_flow(command_scan_type: str, scan_type: str, sync_option: b - for IAC scan, sync flow is always used - for SAST scan, sync flow is not supported - for SCA and Secrets scan, sync flow is supported only for path/repository scan + """ if not sync_option and scan_type != consts.IAC_SCAN_TYPE: return False @@ -160,14 +162,14 @@ def _enrich_scan_result_with_data_from_detection_rules( def _get_scan_documents_thread_func( ctx: typer.Context, is_git_diff: bool, is_commit_range: bool, scan_parameters: dict -) -> Callable[[List[Document]], Tuple[str, CliError, LocalScanResult]]: +) -> Callable[[list[Document]], tuple[str, CliError, LocalScanResult]]: cycode_client = ctx.obj['client'] scan_type = ctx.obj['scan_type'] severity_threshold = ctx.obj['severity_threshold'] sync_option = ctx.obj['sync'] command_scan_type = ctx.info_name - def _scan_batch_thread_func(batch: List[Document]) -> Tuple[str, CliError, LocalScanResult]: + def _scan_batch_thread_func(batch: list[Document]) -> tuple[str, CliError, LocalScanResult]: local_scan_result = error = error_message = None detections_count = relevant_detections_count = zip_file_size = 0 @@ -296,7 +298,7 @@ def scan_commit_range( def scan_documents( ctx: typer.Context, - documents_to_scan: List[Document], + documents_to_scan: list[Document], scan_parameters: dict, is_git_diff: bool = False, is_commit_range: bool = False, @@ -334,13 +336,12 @@ def scan_documents( def scan_commit_range_documents( ctx: typer.Context, - from_documents_to_scan: List[Document], - to_documents_to_scan: List[Document], + from_documents_to_scan: list[Document], + to_documents_to_scan: list[Document], scan_parameters: Optional[dict] = None, timeout: Optional[int] = None, ) -> None: - """Used by SCA only""" - + """In use by SCA only.""" cycode_client = ctx.obj['client'] scan_type = ctx.obj['scan_type'] severity_threshold = ctx.obj['severity_threshold'] @@ -423,13 +424,13 @@ def scan_commit_range_documents( ) -def should_scan_documents(from_documents_to_scan: List[Document], to_documents_to_scan: List[Document]) -> bool: +def should_scan_documents(from_documents_to_scan: list[Document], to_documents_to_scan: list[Document]) -> bool: return len(from_documents_to_scan) > 0 or len(to_documents_to_scan) > 0 def create_local_scan_result( scan_result: ZippedFileScanResult, - documents_to_scan: List[Document], + documents_to_scan: list[Document], command_scan_type: str, scan_type: str, severity_threshold: str, @@ -567,15 +568,15 @@ def print_debug_scan_details(scan_details_response: 'ScanDetailsResponse') -> No def print_results( - ctx: typer.Context, local_scan_results: List[LocalScanResult], errors: Optional[Dict[str, 'CliError']] = None + ctx: typer.Context, local_scan_results: list[LocalScanResult], errors: Optional[dict[str, 'CliError']] = None ) -> None: printer = ctx.obj.get('console_printer') printer.print_scan_results(local_scan_results, errors) def get_document_detections( - scan_result: ZippedFileScanResult, documents_to_scan: List[Document] -) -> List[DocumentDetections]: + scan_result: ZippedFileScanResult, documents_to_scan: list[Document] +) -> list[DocumentDetections]: logger.debug('Getting document detections') document_detections = [] @@ -594,11 +595,11 @@ def get_document_detections( def exclude_irrelevant_document_detections( - document_detections_list: List[DocumentDetections], + document_detections_list: list[DocumentDetections], scan_type: str, command_scan_type: str, severity_threshold: str, -) -> List[DocumentDetections]: +) -> list[DocumentDetections]: relevant_document_detections_list = [] for document_detections in document_detections_list: relevant_detections = exclude_irrelevant_detections( @@ -613,8 +614,7 @@ def exclude_irrelevant_document_detections( def parse_pre_receive_input() -> str: - """ - Parsing input to pushed branch update details + """Parse input to pushed branch update details. Example input: old_value new_value refname @@ -623,7 +623,7 @@ def parse_pre_receive_input() -> str: 973a96d3e925b65941f7c47fa16129f1577d499f 0000000000000000000000000000000000000000 refs/heads/feature-branch 59564ef68745bca38c42fc57a7822efd519a6bd9 3378e52dcfa47fb11ce3a4a520bea5f85d5d0bf3 refs/heads/develop - :return: first branch update details (input's first line) + :return: First branch update details (input's first line) """ # FIXME(MarshalX): this blocks main thread forever if called outside of pre-receive hook pre_receive_input = sys.stdin.read().strip() @@ -648,7 +648,7 @@ def _get_default_scan_parameters(ctx: typer.Context) -> dict: } -def get_scan_parameters(ctx: typer.Context, paths: Optional[Tuple[str]] = None) -> dict: +def get_scan_parameters(ctx: typer.Context, paths: Optional[tuple[str, ...]] = None) -> dict: scan_parameters = _get_default_scan_parameters(ctx) if not paths: @@ -661,6 +661,9 @@ def get_scan_parameters(ctx: typer.Context, paths: Optional[Tuple[str]] = None) return scan_parameters remote_url = try_get_git_remote_url(paths[0]) + if not remote_url: + remote_url = try_to_get_plastic_remote_url(paths[0]) + if remote_url: # TODO(MarshalX): remove hardcode in context ctx.obj['remote_url'] = remote_url @@ -679,20 +682,103 @@ def try_get_git_remote_url(path: str) -> Optional[str]: return None +def _get_plastic_repository_name(path: str) -> Optional[str]: + """Get the name of the Plastic repository from the current working directory. + + The command to execute is: + cm status --header --machinereadable --fieldseparator=":::" + + Example of status header in machine-readable format: + STATUS:::0:::Project/RepoName:::OrgName@ServerInfo + """ + try: + command = [ + 'cm', + 'status', + '--header', + '--machinereadable', + f'--fieldseparator={consts.PLASTIC_VCS_DATA_SEPARATOR}', + ] + + status = shell(command=command, timeout=consts.PLASTIC_VSC_CLI_TIMEOUT, working_directory=path) + if not status: + logger.debug('Failed to get Plastic repository name (command failed)') + return None + + status_parts = status.split(consts.PLASTIC_VCS_DATA_SEPARATOR) + if len(status_parts) < 2: + logger.debug('Failed to parse Plastic repository name (command returned unexpected format)') + return None + + return status_parts[2].strip() + except Exception as e: + logger.debug('Failed to get Plastic repository name', exc_info=e) + return None + + +def _get_plastic_repository_list(working_dir: Optional[str] = None) -> dict[str, str]: + """Get the list of Plastic repositories and their GUIDs. + + The command to execute is: + cm repo list --format="{repname}:::{repguid}" + + Example line with data: + Project/RepoName:::tapo1zqt-wn99-4752-h61m-7d9k79d40r4v + + Each line represents an individual repository. + """ + repo_name_to_guid = {} + + try: + command = ['cm', 'repo', 'ls', f'--format={{repname}}{consts.PLASTIC_VCS_DATA_SEPARATOR}{{repguid}}'] + + status = shell(command=command, timeout=consts.PLASTIC_VSC_CLI_TIMEOUT, working_directory=working_dir) + if not status: + logger.debug('Failed to get Plastic repository list (command failed)') + return repo_name_to_guid + + status_lines = status.splitlines() + for line in status_lines: + data_parts = line.split(consts.PLASTIC_VCS_DATA_SEPARATOR) + if len(data_parts) < 2: + logger.debug('Failed to parse Plastic repository list line (unexpected format), %s', {'line': line}) + continue + + repo_name, repo_guid = data_parts + repo_name_to_guid[repo_name.strip()] = repo_guid.strip() + + return repo_name_to_guid + except Exception as e: + logger.debug('Failed to get Plastic repository list', exc_info=e) + return repo_name_to_guid + + +def try_to_get_plastic_remote_url(path: str) -> Optional[str]: + repository_name = _get_plastic_repository_name(path) + if not repository_name: + return None + + repository_map = _get_plastic_repository_list(path) + if repository_name not in repository_map: + logger.debug('Failed to get Plastic repository GUID (repository not found in the list)') + return None + + repository_guid = repository_map[repository_name] + return f'{consts.PLASTIC_VCS_REMOTE_URI_PREFIX}{repository_guid}' + + def exclude_irrelevant_detections( - detections: List[Detection], scan_type: str, command_scan_type: str, severity_threshold: str -) -> List[Detection]: + detections: list[Detection], scan_type: str, command_scan_type: str, severity_threshold: str +) -> list[Detection]: relevant_detections = _exclude_detections_by_exclusions_configuration(detections, scan_type) relevant_detections = _exclude_detections_by_scan_type(relevant_detections, scan_type, command_scan_type) return _exclude_detections_by_severity(relevant_detections, severity_threshold) -def _exclude_detections_by_severity(detections: List[Detection], severity_threshold: str) -> List[Detection]: +def _exclude_detections_by_severity(detections: list[Detection], severity_threshold: str) -> list[Detection]: relevant_detections = [] for detection in detections: - severity = detection.detection_details.get('advisory_severity') - if not severity: - severity = detection.severity + severity = detection.severity if _does_severity_match_severity_threshold(severity, severity_threshold): relevant_detections.append(detection) @@ -706,8 +792,8 @@ def _exclude_detections_by_severity(detections: List[Detection], severity_thresh def _exclude_detections_by_scan_type( - detections: List[Detection], scan_type: str, command_scan_type: str -) -> List[Detection]: + detections: list[Detection], scan_type: str, command_scan_type: str +) -> list[Detection]: if command_scan_type == consts.PRE_COMMIT_COMMAND_SCAN_TYPE: return exclude_detections_in_deleted_lines(detections) @@ -722,16 +808,16 @@ def _exclude_detections_by_scan_type( return detections -def exclude_detections_in_deleted_lines(detections: List[Detection]) -> List[Detection]: +def exclude_detections_in_deleted_lines(detections: list[Detection]) -> list[Detection]: return [detection for detection in detections if detection.detection_details.get('line_type') != 'Removed'] -def _exclude_detections_by_exclusions_configuration(detections: List[Detection], scan_type: str) -> List[Detection]: +def _exclude_detections_by_exclusions_configuration(detections: list[Detection], scan_type: str) -> list[Detection]: exclusions = configuration_manager.get_exclusions_by_scan_type(scan_type) return [detection for detection in detections if not _should_exclude_detection(detection, exclusions)] -def _should_exclude_detection(detection: Detection, exclusions: Dict) -> bool: +def _should_exclude_detection(detection: Detection, exclusions: dict) -> bool: # FIXME(MarshalX): what the difference between by_value and by_sha? exclusions_by_value = exclusions.get(consts.EXCLUSIONS_BY_VALUE_SECTION_NAME, []) if _is_detection_sha_configured_in_exclusions(detection, exclusions_by_value): @@ -773,7 +859,7 @@ def _should_exclude_detection(detection: Detection, exclusions: Dict) -> bool: return False -def _is_detection_sha_configured_in_exclusions(detection: Detection, exclusions: List[str]) -> bool: +def _is_detection_sha_configured_in_exclusions(detection: Detection, exclusions: list[str]) -> bool: detection_sha = detection.detection_details.get('sha512') return detection_sha in exclusions @@ -797,7 +883,7 @@ def _get_cve_identifier(detection: Detection) -> Optional[str]: def _get_document_by_file_name( - documents: List[Document], file_name: str, unique_id: Optional[str] = None + documents: list[Document], file_name: str, unique_id: Optional[str] = None ) -> Optional[Document]: for document in documents: if _normalize_file_path(document.path) == _normalize_file_path(file_name) and document.unique_id == unique_id: @@ -903,10 +989,11 @@ def _try_get_aggregation_report_url_if_needed( logger.debug('Failed to get aggregation report url: %s', str(e)) -def _map_detections_per_file_and_commit_id(scan_type: str, raw_detections: List[dict]) -> List[DetectionsPerFile]: - """Converts list of detections (async flow) to list of DetectionsPerFile objects (sync flow). +def _map_detections_per_file_and_commit_id(scan_type: str, raw_detections: list[dict]) -> list[DetectionsPerFile]: + """Convert a list of detections (async flow) to list of DetectionsPerFile objects (sync flow). Args: + scan_type: Type of the scan. raw_detections: List of detections as is returned from the server. Note: @@ -915,6 +1002,7 @@ def _map_detections_per_file_and_commit_id(scan_type: str, raw_detections: List[ Note: Aggregation is performed by file name and commit ID (if available) + """ detections_per_files = {} for raw_detection in raw_detections: @@ -956,7 +1044,7 @@ def _get_secret_file_name_from_detection(raw_detection: dict) -> str: return os.path.join(file_path, file_name) -def _does_reach_to_max_commits_to_scan_limit(commit_ids: List[str], max_commits_count: Optional[int]) -> bool: +def _does_reach_to_max_commits_to_scan_limit(commit_ids: list[str], max_commits_count: Optional[int]) -> bool: if max_commits_count is None: return False diff --git a/cycode/cli/apps/scan/commit_history/commit_history_command.py b/cycode/cli/apps/scan/commit_history/commit_history_command.py index f7992a92..fc1ef23f 100644 --- a/cycode/cli/apps/scan/commit_history/commit_history_command.py +++ b/cycode/cli/apps/scan/commit_history/commit_history_command.py @@ -12,14 +12,14 @@ def commit_history_command( ctx: typer.Context, path: Annotated[ - Path, typer.Argument(exists=True, resolve_path=True, help='Path to git repository to scan', show_default=False) + Path, typer.Argument(exists=True, resolve_path=True, help='Path to Git repository to scan', show_default=False) ], commit_range: Annotated[ str, typer.Option( - '--commit_range', + '--commit-range', '-r', - help='Scan a commit range in this git repository (example: HEAD~1)', + help='Scan a commit range in this Git repository (example: HEAD~1)', show_default='cycode scans all commit history', ), ] = '--all', diff --git a/cycode/cli/apps/scan/path/path_command.py b/cycode/cli/apps/scan/path/path_command.py index 48db40ac..3ee87350 100644 --- a/cycode/cli/apps/scan/path/path_command.py +++ b/cycode/cli/apps/scan/path/path_command.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Annotated, List +from typing import Annotated import typer @@ -11,7 +11,7 @@ def path_command( ctx: typer.Context, paths: Annotated[ - List[Path], typer.Argument(exists=True, resolve_path=True, help='Paths to scan', show_default=False) + list[Path], typer.Argument(exists=True, resolve_path=True, help='Paths to scan', show_default=False) ], ) -> None: add_breadcrumb('path') diff --git a/cycode/cli/apps/scan/pre_commit/pre_commit_command.py b/cycode/cli/apps/scan/pre_commit/pre_commit_command.py index 8e528d15..b919d659 100644 --- a/cycode/cli/apps/scan/pre_commit/pre_commit_command.py +++ b/cycode/cli/apps/scan/pre_commit/pre_commit_command.py @@ -1,5 +1,5 @@ import os -from typing import Annotated, List, Optional +from typing import Annotated, Optional import typer @@ -21,7 +21,7 @@ def pre_commit_command( ctx: typer.Context, - _: Annotated[Optional[List[str]], typer.Argument(help='Ignored arguments', hidden=True)] = None, + _: Annotated[Optional[list[str]], typer.Argument(help='Ignored arguments', hidden=True)] = None, ) -> None: add_breadcrumb('pre_commit') diff --git a/cycode/cli/apps/scan/pre_receive/pre_receive_command.py b/cycode/cli/apps/scan/pre_receive/pre_receive_command.py index 01242b24..eb4f1420 100644 --- a/cycode/cli/apps/scan/pre_receive/pre_receive_command.py +++ b/cycode/cli/apps/scan/pre_receive/pre_receive_command.py @@ -1,5 +1,5 @@ import os -from typing import Annotated, List, Optional +from typing import Annotated, Optional import click import typer @@ -25,7 +25,7 @@ def pre_receive_command( ctx: typer.Context, - _: Annotated[Optional[List[str]], typer.Argument(help='Ignored arguments', hidden=True)] = None, + _: Annotated[Optional[list[str]], typer.Argument(help='Ignored arguments', hidden=True)] = None, ) -> None: try: add_breadcrumb('pre_receive') diff --git a/cycode/cli/apps/scan/repository/repository_command.py b/cycode/cli/apps/scan/repository/repository_command.py index a99cc2d1..16ad8611 100644 --- a/cycode/cli/apps/scan/repository/repository_command.py +++ b/cycode/cli/apps/scan/repository/repository_command.py @@ -21,7 +21,7 @@ def repository_command( ctx: typer.Context, path: Annotated[ - Path, typer.Argument(exists=True, resolve_path=True, help='Path to git repository to scan.', show_default=False) + Path, typer.Argument(exists=True, resolve_path=True, help='Path to Git repository to scan.', show_default=False) ], branch: Annotated[ Optional[str], typer.Option('--branch', '-b', help='Branch to scan.', show_default='default branch') diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 84485c0b..38e4a610 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -1,4 +1,4 @@ -from typing import Annotated, List, Optional +from typing import Annotated, Optional import click import typer @@ -67,7 +67,7 @@ def scan_command( ), ] = False, sca_scan: Annotated[ - List[ScaScanTypeOption], + list[ScaScanTypeOption], typer.Option( help='Specify the type of SCA scan you wish to execute.', rich_help_panel=_SCA_RICH_HELP_PANEL, @@ -85,7 +85,7 @@ def scan_command( bool, typer.Option( '--no-restore', - help='When specified, Cycode will not run restore command. ' 'Will scan direct dependencies [b]only[/]!', + help='When specified, Cycode will not run restore command. Will scan direct dependencies [b]only[/]!', rich_help_panel=_SCA_RICH_HELP_PANEL, ), ] = False, @@ -99,9 +99,20 @@ def scan_command( ), ] = False, ) -> None: - """:magnifying_glass_tilted_right: Scan the content for Secrets, IaC, SCA, and SAST violations. - You'll need to specify which scan type to perform: - [cyan]path[/]/[cyan]repository[/]/[cyan]commit_history[/].""" + """:mag: [bold cyan]Scan code for vulnerabilities (Secrets, IaC, SCA, SAST).[/] + + This command scans your code for various types of security issues, including: + * [yellow]Secrets:[/] Hardcoded credentials and sensitive information. + * [dodger_blue1]Infrastructure as Code (IaC):[/] Misconfigurations in Terraform, CloudFormation, etc. + * [green]Software Composition Analysis (SCA):[/] Vulnerabilities and license issues in dependencies. + * [magenta]Static Application Security Testing (SAST):[/] Code quality and security flaws. + + Example usage: + * `cycode scan path `: Scan a specific local directory or file. + * `cycode scan repository `: Scan Git related files in a local Git repository. + * `cycode scan commit-history `: Scan the commit history of a local Git repository. + + """ add_breadcrumb('scan') ctx.obj['show_secret'] = show_secret @@ -118,7 +129,7 @@ def scan_command( _sca_scan_to_context(ctx, sca_scan) -def _sca_scan_to_context(ctx: typer.Context, sca_scan_user_selected: List[str]) -> None: +def _sca_scan_to_context(ctx: typer.Context, sca_scan_user_selected: list[str]) -> None: for sca_scan_option_selected in sca_scan_user_selected: ctx.obj[sca_scan_option_selected] = True diff --git a/cycode/cli/apps/status/get_cli_status.py b/cycode/cli/apps/status/get_cli_status.py index 4a3dc4b0..0a272c57 100644 --- a/cycode/cli/apps/status/get_cli_status.py +++ b/cycode/cli/apps/status/get_cli_status.py @@ -1,4 +1,5 @@ import platform +from typing import TYPE_CHECKING from cycode import __version__ from cycode.cli.apps.auth.auth_common import get_authorization_info @@ -8,11 +9,14 @@ from cycode.cli.user_settings.configuration_manager import ConfigurationManager from cycode.cli.utils.get_api_client import get_scan_cycode_client +if TYPE_CHECKING: + from typer import Context -def get_cli_status() -> CliStatus: + +def get_cli_status(ctx: 'Context') -> CliStatus: configuration_manager = ConfigurationManager() - auth_info = get_authorization_info() + auth_info = get_authorization_info(ctx) is_authenticated = auth_info is not None supported_modules_status = CliSupportedModulesStatus() diff --git a/cycode/cli/apps/status/models.py b/cycode/cli/apps/status/models.py index 50182ecd..82b9751a 100644 --- a/cycode/cli/apps/status/models.py +++ b/cycode/cli/apps/status/models.py @@ -1,10 +1,9 @@ import json from dataclasses import asdict, dataclass -from typing import Dict class CliStatusBase: - def as_dict(self) -> Dict[str, any]: + def as_dict(self) -> dict[str, any]: return asdict(self) def _get_text_message_part(self, key: str, value: any, intent: int = 0) -> str: diff --git a/cycode/cli/apps/status/status_command.py b/cycode/cli/apps/status/status_command.py index 28f8cfba..4654ef20 100644 --- a/cycode/cli/apps/status/status_command.py +++ b/cycode/cli/apps/status/status_command.py @@ -6,9 +6,25 @@ def status_command(ctx: typer.Context) -> None: + """:information_source: [bold cyan]Show Cycode CLI status and configuration.[/] + + This command displays the current status and configuration of the Cycode CLI, including: + * Authentication status: Whether you're logged in + * Version information: Current CLI version + * Configuration: Current API endpoints and settings + * System information: Operating system and environment details + + Output formats: + * Text: Human-readable format (default) + * JSON: Machine-readable format + + Example usage: + * `cycode status`: Show status in text format + * `cycode -o json status`: Show status in JSON format + """ output = ctx.obj['output'] - cli_status = get_cli_status() + cli_status = get_cli_status(ctx) if output == OutputTypeOption.JSON: console.print_json(cli_status.as_json()) else: diff --git a/cycode/cli/config.py b/cycode/cli/config.py index a1ddbbaf..73491546 100644 --- a/cycode/cli/config.py +++ b/cycode/cli/config.py @@ -4,4 +4,4 @@ # env vars CYCODE_CLIENT_ID_ENV_VAR_NAME = 'CYCODE_CLIENT_ID' -CYCODE_CLIENT_SECRET_ENV_VAR_NAME = 'CYCODE_CLIENT_SECRET' # noqa: S105 +CYCODE_CLIENT_SECRET_ENV_VAR_NAME = 'CYCODE_CLIENT_SECRET' diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 9d7a619d..286f1f95 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -9,7 +9,7 @@ COMMIT_HISTORY_COMMAND_SCAN_TYPE = 'commit-history' COMMIT_HISTORY_COMMAND_SCAN_TYPE_OLD = 'commit_history' -SECRET_SCAN_TYPE = 'secret' # noqa: S105 +SECRET_SCAN_TYPE = 'secret' IAC_SCAN_TYPE = 'iac' SCA_SCAN_TYPE = 'sca' SAST_SCAN_TYPE = 'sast' @@ -231,3 +231,11 @@ # Example: A -> B -> C # Result: A -> ... -> C SCA_SHORTCUT_DEPENDENCY_PATHS = 2 + +SCA_SKIP_RESTORE_DEPENDENCIES_FLAG = 'no-restore' + +SCA_GRADLE_ALL_SUB_PROJECTS_FLAG = 'gradle-all-sub-projects' + +PLASTIC_VCS_DATA_SEPARATOR = ':::' +PLASTIC_VSC_CLI_TIMEOUT = 10 +PLASTIC_VCS_REMOTE_URI_PREFIX = 'plastic::' diff --git a/cycode/cli/exceptions/custom_exceptions.py b/cycode/cli/exceptions/custom_exceptions.py index 4d692812..59c0f693 100644 --- a/cycode/cli/exceptions/custom_exceptions.py +++ b/cycode/cli/exceptions/custom_exceptions.py @@ -4,7 +4,7 @@ class CycodeError(Exception): - """Base class for all custom exceptions""" + """Base class for all custom exceptions.""" def __str__(self) -> str: class_name = self.__class__.__name__ @@ -14,7 +14,7 @@ def __str__(self) -> str: class RequestError(CycodeError): ... -class RequestTimeout(RequestError): ... +class RequestTimeoutError(RequestError): ... class RequestConnectionError(RequestError): ... @@ -91,7 +91,7 @@ def __str__(self) -> str: code='cycode_error', message='Cycode was unable to complete this scan. Please try again by executing the `cycode scan` command', ), - RequestTimeout: CliError( + RequestTimeoutError: CliError( soft_fail=True, code='timeout_error', message='The request timed out. Please try again by executing the `cycode scan` command', diff --git a/cycode/cli/exceptions/handle_scan_errors.py b/cycode/cli/exceptions/handle_scan_errors.py index 09890247..229e0f02 100644 --- a/cycode/cli/exceptions/handle_scan_errors.py +++ b/cycode/cli/exceptions/handle_scan_errors.py @@ -17,8 +17,7 @@ def handle_scan_exception(ctx: typer.Context, err: Exception, *, return_exceptio custom_exceptions.ScanAsyncError: CliError( soft_fail=True, code='scan_error', - message='Cycode was unable to complete this scan. ' - 'Please try again by executing the `cycode scan` command', + message='Cycode was unable to complete this scan. Please try again by executing the `cycode scan` command', ), custom_exceptions.ZipTooLargeError: CliError( soft_fail=True, @@ -38,7 +37,7 @@ def handle_scan_exception(ctx: typer.Context, err: Exception, *, return_exceptio git_proxy.get_invalid_git_repository_error(): CliError( soft_fail=False, code='invalid_git_error', - message='The path you supplied does not correlate to a git repository. ' + message='The path you supplied does not correlate to a Git repository. ' 'If you still wish to scan this path, use: `cycode scan path `', ), } diff --git a/cycode/cli/files_collector/excluder.py b/cycode/cli/files_collector/excluder.py index f16e9710..9ef5e3d6 100644 --- a/cycode/cli/files_collector/excluder.py +++ b/cycode/cli/files_collector/excluder.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING from cycode.cli import consts from cycode.cli.config import configuration_manager @@ -16,8 +16,8 @@ def exclude_irrelevant_files( - progress_bar: 'BaseProgressBar', progress_bar_section: 'ProgressBarSection', scan_type: str, filenames: List[str] -) -> List[str]: + progress_bar: 'BaseProgressBar', progress_bar_section: 'ProgressBarSection', scan_type: str, filenames: list[str] +) -> list[str]: relevant_files = [] for filename in filenames: progress_bar.update(progress_bar_section) @@ -29,7 +29,7 @@ def exclude_irrelevant_files( return relevant_files -def exclude_irrelevant_documents_to_scan(scan_type: str, documents_to_scan: List['Document']) -> List['Document']: +def exclude_irrelevant_documents_to_scan(scan_type: str, documents_to_scan: list['Document']) -> list['Document']: logger.debug('Excluding irrelevant documents to scan') relevant_documents = [] diff --git a/cycode/cli/files_collector/iac/tf_content_generator.py b/cycode/cli/files_collector/iac/tf_content_generator.py index 8f4cb4d0..63be9e47 100644 --- a/cycode/cli/files_collector/iac/tf_content_generator.py +++ b/cycode/cli/files_collector/iac/tf_content_generator.py @@ -1,6 +1,5 @@ import json import time -from typing import List from cycode.cli import consts from cycode.cli.exceptions.custom_exceptions import TfplanKeyError @@ -34,7 +33,7 @@ def generate_tf_content_from_tfplan(filename: str, tfplan: str) -> str: return _generate_tf_content(planned_resources) -def _generate_tf_content(resource_changes: List[ResourceChange]) -> str: +def _generate_tf_content(resource_changes: list[ResourceChange]) -> str: tf_content = '' for resource_change in resource_changes: if not any(item in resource_change.actions for item in ACTIONS_TO_OMIT_RESOURCE): @@ -62,9 +61,9 @@ def _get_resource_name(resource_change: ResourceChange) -> str: return '.'.join(valid_parts) -def _extract_resources(tfplan: str, filename: str) -> List[ResourceChange]: +def _extract_resources(tfplan: str, filename: str) -> list[ResourceChange]: tfplan_json = load_json(tfplan) - resources: List[ResourceChange] = [] + resources: list[ResourceChange] = [] try: resource_changes = tfplan_json['resource_changes'] for resource_change in resource_changes: diff --git a/cycode/cli/files_collector/models/in_memory_zip.py b/cycode/cli/files_collector/models/in_memory_zip.py index a0700f6b..8f58b12b 100644 --- a/cycode/cli/files_collector/models/in_memory_zip.py +++ b/cycode/cli/files_collector/models/in_memory_zip.py @@ -10,7 +10,7 @@ from pathlib import Path -class InMemoryZip(object): +class InMemoryZip: def __init__(self) -> None: self.configuration_manager = ConfigurationManager() diff --git a/cycode/cli/files_collector/path_documents.py b/cycode/cli/files_collector/path_documents.py index 469e6ce7..e0f06312 100644 --- a/cycode/cli/files_collector/path_documents.py +++ b/cycode/cli/files_collector/path_documents.py @@ -1,5 +1,5 @@ import os -from typing import TYPE_CHECKING, List, Tuple +from typing import TYPE_CHECKING from cycode.cli.files_collector.excluder import exclude_irrelevant_files from cycode.cli.files_collector.iac.tf_content_generator import ( @@ -17,8 +17,8 @@ from cycode.cli.utils.progress_bar import BaseProgressBar, ProgressBarSection -def _get_all_existing_files_in_directory(path: str, *, walk_with_ignore_patterns: bool = True) -> List[str]: - files: List[str] = [] +def _get_all_existing_files_in_directory(path: str, *, walk_with_ignore_patterns: bool = True) -> list[str]: + files: list[str] = [] walk_func = walk_ignore if walk_with_ignore_patterns else os.walk for root, _, filenames in walk_func(path): @@ -28,7 +28,7 @@ def _get_all_existing_files_in_directory(path: str, *, walk_with_ignore_patterns return files -def _get_relevant_files_in_path(path: str) -> List[str]: +def _get_relevant_files_in_path(path: str) -> list[str]: absolute_path = get_absolute_path(path) if not os.path.isfile(absolute_path) and not os.path.isdir(absolute_path): @@ -42,8 +42,8 @@ def _get_relevant_files_in_path(path: str) -> List[str]: def _get_relevant_files( - progress_bar: 'BaseProgressBar', progress_bar_section: 'ProgressBarSection', scan_type: str, paths: Tuple[str, ...] -) -> List[str]: + progress_bar: 'BaseProgressBar', progress_bar_section: 'ProgressBarSection', scan_type: str, paths: tuple[str, ...] +) -> list[str]: all_files_to_scan = [] for path in paths: all_files_to_scan.extend(_get_relevant_files_in_path(path)) @@ -89,13 +89,13 @@ def get_relevant_documents( progress_bar: 'BaseProgressBar', progress_bar_section: 'ProgressBarSection', scan_type: str, - paths: Tuple[str, ...], + paths: tuple[str, ...], *, is_git_diff: bool = False, -) -> List[Document]: +) -> list[Document]: relevant_files = _get_relevant_files(progress_bar, progress_bar_section, scan_type, paths) - documents: List[Document] = [] + documents: list[Document] = [] for file in relevant_files: progress_bar.update(progress_bar_section) diff --git a/cycode/cli/files_collector/repository_documents.py b/cycode/cli/files_collector/repository_documents.py index df49aa95..b524ca4c 100644 --- a/cycode/cli/files_collector/repository_documents.py +++ b/cycode/cli/files_collector/repository_documents.py @@ -1,5 +1,6 @@ import os -from typing import TYPE_CHECKING, Iterator, List, Optional, Tuple, Union +from collections.abc import Iterator +from typing import TYPE_CHECKING, Optional, Union from cycode.cli import consts from cycode.cli.files_collector.sca import sca_code_scanner @@ -25,7 +26,7 @@ def get_git_repository_tree_file_entries( return git_proxy.get_repo(path).tree(branch).traverse(predicate=should_process_git_object) -def parse_commit_range(commit_range: str, path: str) -> Tuple[str, str]: +def parse_commit_range(commit_range: str, path: str) -> tuple[str, str]: from_commit_rev = None to_commit_rev = None @@ -47,7 +48,7 @@ def get_diff_file_content(file: 'Diff') -> str: def get_pre_commit_modified_documents( progress_bar: 'BaseProgressBar', progress_bar_section: 'ProgressBarSection' -) -> Tuple[List[Document], List[Document]]: +) -> tuple[list[Document], list[Document]]: git_head_documents = [] pre_committed_documents = [] @@ -77,7 +78,7 @@ def get_commit_range_modified_documents( path: str, from_commit_rev: str, to_commit_rev: str, -) -> Tuple[List[Document], List[Document]]: +) -> tuple[list[Document], list[Document]]: from_commit_documents = [] to_commit_documents = [] diff --git a/cycode/cli/files_collector/sca/base_restore_dependencies.py b/cycode/cli/files_collector/sca/base_restore_dependencies.py index 2e6c0993..c4364c05 100644 --- a/cycode/cli/files_collector/sca/base_restore_dependencies.py +++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import List, Optional +from typing import Optional import typer @@ -14,7 +14,7 @@ def build_dep_tree_path(path: str, generated_file_name: str) -> str: def execute_commands( - commands: List[List[str]], + commands: list[list[str]], file_name: str, command_timeout: int, dependencies_file_name: Optional[str] = None, @@ -91,7 +91,7 @@ def is_project(self, document: Document) -> bool: pass @abstractmethod - def get_commands(self, manifest_file_path: str) -> List[List[str]]: + def get_commands(self, manifest_file_path: str) -> list[list[str]]: pass @abstractmethod diff --git a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py index 5d56644a..4f469896 100644 --- a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py +++ b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py @@ -1,5 +1,5 @@ import os -from typing import List, Optional +from typing import Optional import typer @@ -34,7 +34,7 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: def is_project(self, document: Document) -> bool: return any(document.path.endswith(ext) for ext in GO_PROJECT_FILE_EXTENSIONS) - def get_commands(self, manifest_file_path: str) -> List[List[str]]: + def get_commands(self, manifest_file_path: str) -> list[list[str]]: return [ ['go', 'list', '-m', '-json', 'all'], ['echo', '------------------------------------------------------'], diff --git a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py index 3995da90..89595e0e 100644 --- a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py @@ -1,6 +1,6 @@ import os import re -from typing import List, Optional, Set +from typing import Optional import typer @@ -19,7 +19,7 @@ class RestoreGradleDependencies(BaseRestoreDependencies): def __init__( - self, ctx: typer.Context, is_git_diff: bool, command_timeout: int, projects: Optional[Set[str]] = None + self, ctx: typer.Context, is_git_diff: bool, command_timeout: int, projects: Optional[set[str]] = None ) -> None: super().__init__(ctx, is_git_diff, command_timeout, create_output_file_manually=True) if projects is None: @@ -32,7 +32,7 @@ def is_gradle_sub_projects(self) -> bool: def is_project(self, document: Document) -> bool: return document.path.endswith(BUILD_GRADLE_FILE_NAME) or document.path.endswith(BUILD_GRADLE_KTS_FILE_NAME) - def get_commands(self, manifest_file_path: str) -> List[List[str]]: + def get_commands(self, manifest_file_path: str) -> list[list[str]]: return ( self.get_commands_for_sub_projects(manifest_file_path) if self.is_gradle_sub_projects() @@ -48,7 +48,7 @@ def verify_restore_file_already_exist(self, restore_file_path: str) -> bool: def get_working_directory(self, document: Document) -> Optional[str]: return get_path_from_context(self.ctx) if self.is_gradle_sub_projects() else None - def get_all_projects(self) -> Set[str]: + def get_all_projects(self) -> set[str]: projects_output = shell( command=BUILD_GRADLE_ALL_PROJECTS_COMMAND, timeout=BUILD_GRADLE_ALL_PROJECTS_TIMEOUT, @@ -59,7 +59,7 @@ def get_all_projects(self) -> Set[str]: return set(projects) - def get_commands_for_sub_projects(self, manifest_file_path: str) -> List[List[str]]: + def get_commands_for_sub_projects(self, manifest_file_path: str) -> list[list[str]]: project_name = os.path.basename(os.path.dirname(manifest_file_path)) project_name = f':{project_name}' return ( diff --git a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py index d90bbe71..1c3d860c 100644 --- a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py @@ -1,6 +1,6 @@ import os from os import path -from typing import List, Optional +from typing import Optional import typer @@ -24,7 +24,7 @@ def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) def is_project(self, document: Document) -> bool: return path.basename(document.path).split('/')[-1] == BUILD_MAVEN_FILE_NAME - def get_commands(self, manifest_file_path: str) -> List[List[str]]: + def get_commands(self, manifest_file_path: str) -> list[list[str]]: return [['mvn', 'org.cyclonedx:cyclonedx-maven-plugin:2.7.4:makeAggregateBom', '-f', manifest_file_path]] def get_lock_file_name(self) -> str: @@ -64,7 +64,7 @@ def restore_from_secondary_command( return restore_dependencies -def create_secondary_restore_command(manifest_file_path: str) -> List[str]: +def create_secondary_restore_command(manifest_file_path: str) -> list[str]: return [ 'mvn', 'dependency:tree', diff --git a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py index 672ee0db..ed8e36c2 100644 --- a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py +++ b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py @@ -1,5 +1,4 @@ import os -from typing import List import typer @@ -18,7 +17,7 @@ def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) def is_project(self, document: Document) -> bool: return any(document.path.endswith(ext) for ext in NPM_PROJECT_FILE_EXTENSIONS) - def get_commands(self, manifest_file_path: str) -> List[List[str]]: + def get_commands(self, manifest_file_path: str) -> list[list[str]]: return [ [ 'npm', diff --git a/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py b/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py index b4f5a248..3bd6627f 100644 --- a/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py +++ b/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py @@ -1,5 +1,4 @@ import os -from typing import List import typer @@ -17,7 +16,7 @@ def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) def is_project(self, document: Document) -> bool: return any(document.path.endswith(ext) for ext in NUGET_PROJECT_FILE_EXTENSIONS) - def get_commands(self, manifest_file_path: str) -> List[List[str]]: + def get_commands(self, manifest_file_path: str) -> list[list[str]]: return [['dotnet', 'restore', manifest_file_path, '--use-lock-file', '--verbosity', 'quiet']] def get_lock_file_name(self) -> str: diff --git a/cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py b/cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py index 3dfc4a16..4571b1c5 100644 --- a/cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py +++ b/cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py @@ -1,5 +1,5 @@ import os -from typing import List, Optional +from typing import Optional from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies from cycode.cli.models import Document @@ -12,7 +12,7 @@ class RestoreRubyDependencies(BaseRestoreDependencies): def is_project(self, document: Document) -> bool: return any(document.path.endswith(ext) for ext in RUBY_PROJECT_FILE_EXTENSIONS) - def get_commands(self, manifest_file_path: str) -> List[List[str]]: + def get_commands(self, manifest_file_path: str) -> list[list[str]]: return [['bundle', '--quiet']] def get_lock_file_name(self) -> str: diff --git a/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py b/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py index b8e1c41b..d7eeba3b 100644 --- a/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py +++ b/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py @@ -1,5 +1,5 @@ import os -from typing import List, Optional +from typing import Optional from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies from cycode.cli.models import Document @@ -12,7 +12,7 @@ class RestoreSbtDependencies(BaseRestoreDependencies): def is_project(self, document: Document) -> bool: return any(document.path.endswith(ext) for ext in SBT_PROJECT_FILE_EXTENSIONS) - def get_commands(self, manifest_file_path: str) -> List[List[str]]: + def get_commands(self, manifest_file_path: str) -> list[list[str]]: return [['sbt', 'dependencyLockWrite', '--verbose']] def get_lock_file_name(self) -> str: diff --git a/cycode/cli/files_collector/sca/sca_code_scanner.py b/cycode/cli/files_collector/sca/sca_code_scanner.py index 88626c9c..e6ec0e9d 100644 --- a/cycode/cli/files_collector/sca/sca_code_scanner.py +++ b/cycode/cli/files_collector/sca/sca_code_scanner.py @@ -1,5 +1,5 @@ import os -from typing import TYPE_CHECKING, Dict, List, Optional +from typing import TYPE_CHECKING, Optional import typer @@ -28,9 +28,9 @@ def perform_pre_commit_range_scan_actions( path: str, - from_commit_documents: List[Document], + from_commit_documents: list[Document], from_commit_rev: str, - to_commit_documents: List[Document], + to_commit_documents: list[Document], to_commit_rev: str, ) -> None: repo = git_proxy.get_repo(path) @@ -39,7 +39,7 @@ def perform_pre_commit_range_scan_actions( def perform_pre_hook_range_scan_actions( - git_head_documents: List[Document], pre_committed_documents: List[Document] + git_head_documents: list[Document], pre_committed_documents: list[Document] ) -> None: repo = git_proxy.get_repo(os.getcwd()) add_ecosystem_related_files_if_exists(git_head_documents, repo, consts.GIT_HEAD_COMMIT_REV) @@ -47,9 +47,9 @@ def perform_pre_hook_range_scan_actions( def add_ecosystem_related_files_if_exists( - documents: List[Document], repo: Optional['Repo'] = None, commit_rev: Optional[str] = None + documents: list[Document], repo: Optional['Repo'] = None, commit_rev: Optional[str] = None ) -> None: - documents_to_add: List[Document] = [] + documents_to_add: list[Document] = [] for doc in documents: ecosystem = get_project_file_ecosystem(doc) if ecosystem is None: @@ -62,9 +62,9 @@ def add_ecosystem_related_files_if_exists( def get_doc_ecosystem_related_project_files( - doc: Document, documents: List[Document], ecosystem: str, commit_rev: Optional[str], repo: Optional['Repo'] -) -> List[Document]: - documents_to_add: List[Document] = [] + doc: Document, documents: list[Document], ecosystem: str, commit_rev: Optional[str], repo: Optional['Repo'] +) -> list[Document]: + documents_to_add: list[Document] = [] for ecosystem_project_file in consts.PROJECT_FILES_BY_ECOSYSTEM_MAP.get(ecosystem): file_to_search = join_paths(get_file_dir(doc.path), ecosystem_project_file) if not is_project_file_exists_in_documents(documents, file_to_search): @@ -79,7 +79,7 @@ def get_doc_ecosystem_related_project_files( return documents_to_add -def is_project_file_exists_in_documents(documents: List[Document], file: str) -> bool: +def is_project_file_exists_in_documents(documents: list[Document], file: str) -> bool: return any(doc for doc in documents if file == doc.path) @@ -93,7 +93,7 @@ def get_project_file_ecosystem(document: Document) -> Optional[str]: def try_restore_dependencies( ctx: typer.Context, - documents_to_add: Dict[str, Document], + documents_to_add: dict[str, Document], restore_dependencies: 'BaseRestoreDependencies', document: Document, ) -> None: @@ -122,9 +122,9 @@ def try_restore_dependencies( def add_dependencies_tree_document( - ctx: typer.Context, documents_to_scan: List[Document], is_git_diff: bool = False + ctx: typer.Context, documents_to_scan: list[Document], is_git_diff: bool = False ) -> None: - documents_to_add: Dict[str, Document] = {document.path: document for document in documents_to_scan} + documents_to_add: dict[str, Document] = {document.path: document for document in documents_to_scan} restore_dependencies_list = restore_handlers(ctx, is_git_diff) for restore_dependencies in restore_dependencies_list: @@ -135,7 +135,7 @@ def add_dependencies_tree_document( documents_to_scan[:] = list(documents_to_add.values()) -def restore_handlers(ctx: typer.Context, is_git_diff: bool) -> List[BaseRestoreDependencies]: +def restore_handlers(ctx: typer.Context, is_git_diff: bool) -> list[BaseRestoreDependencies]: return [ RestoreGradleDependencies(ctx, is_git_diff, BUILD_DEP_TREE_TIMEOUT), RestoreMavenDependencies(ctx, is_git_diff, BUILD_DEP_TREE_TIMEOUT), @@ -159,7 +159,7 @@ def get_file_content_from_commit(repo: 'Repo', commit: str, file_path: str) -> O def perform_pre_scan_documents_actions( - ctx: typer.Context, scan_type: str, documents_to_scan: List[Document], is_git_diff: bool = False + ctx: typer.Context, scan_type: str, documents_to_scan: list[Document], is_git_diff: bool = False ) -> None: no_restore = ctx.params.get('no-restore', False) if scan_type == consts.SCA_SCAN_TYPE and not no_restore: diff --git a/cycode/cli/files_collector/walk_ignore.py b/cycode/cli/files_collector/walk_ignore.py index 0ba2b93d..35855ff4 100644 --- a/cycode/cli/files_collector/walk_ignore.py +++ b/cycode/cli/files_collector/walk_ignore.py @@ -1,5 +1,5 @@ import os -from typing import Generator, Iterable, List, Tuple +from collections.abc import Generator, Iterable from cycode.cli.logger import logger from cycode.cli.utils.ignore_utils import IgnoreFilterManager @@ -22,7 +22,7 @@ def _walk_to_top(path: str) -> Iterable[str]: yield path # Include the top-level directory -def _collect_top_level_ignore_files(path: str) -> List[str]: +def _collect_top_level_ignore_files(path: str) -> list[str]: ignore_files = [] top_paths = reversed(list(_walk_to_top(path))) # we must reverse it to make top levels more prioritized for dir_path in top_paths: @@ -34,7 +34,7 @@ def _collect_top_level_ignore_files(path: str) -> List[str]: return ignore_files -def walk_ignore(path: str) -> Generator[Tuple[str, List[str], List[str]], None, None]: +def walk_ignore(path: str) -> Generator[tuple[str, list[str], list[str]], None, None]: ignore_filter_manager = IgnoreFilterManager.build( path=path, global_ignore_file_paths=_collect_top_level_ignore_files(path), diff --git a/cycode/cli/files_collector/zip_documents.py b/cycode/cli/files_collector/zip_documents.py index b9a272e1..770121fa 100644 --- a/cycode/cli/files_collector/zip_documents.py +++ b/cycode/cli/files_collector/zip_documents.py @@ -1,6 +1,6 @@ import timeit from pathlib import Path -from typing import List, Optional +from typing import Optional from cycode.cli import consts from cycode.cli.exceptions import custom_exceptions @@ -17,7 +17,7 @@ def _validate_zip_file_size(scan_type: str, zip_file_size: int) -> None: raise custom_exceptions.ZipTooLargeError(max_size_limit) -def zip_documents(scan_type: str, documents: List[Document], zip_file: Optional[InMemoryZip] = None) -> InMemoryZip: +def zip_documents(scan_type: str, documents: list[Document], zip_file: Optional[InMemoryZip] = None) -> InMemoryZip: if zip_file is None: zip_file = InMemoryZip() diff --git a/cycode/cli/models.py b/cycode/cli/models.py index 14058f0c..3c59eeee 100644 --- a/cycode/cli/models.py +++ b/cycode/cli/models.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Dict, List, NamedTuple, Optional, Type +from typing import NamedTuple, Optional from cycode.cyclient.models import Detection @@ -20,16 +20,16 @@ def __init__( self.absolute_path = absolute_path def __repr__(self) -> str: - return 'path:{0}, content:{1}'.format(self.path, self.content) + return f'path:{self.path}, content:{self.content}' class DocumentDetections: - def __init__(self, document: Document, detections: List[Detection]) -> None: + def __init__(self, document: Document, detections: list[Detection]) -> None: self.document = document self.detections = detections def __repr__(self) -> str: - return 'document:{0}, detections:{1}'.format(self.document, self.detections) + return f'document:{self.document}, detections:{self.detections}' class CliError(NamedTuple): @@ -42,19 +42,19 @@ def enrich(self, additional_message: str) -> 'CliError': return CliError(self.code, message, self.soft_fail) -CliErrors = Dict[Type[BaseException], CliError] +CliErrors = dict[type[BaseException], CliError] class CliResult(NamedTuple): success: bool message: str - data: Optional[Dict[str, any]] = None + data: Optional[dict[str, any]] = None class LocalScanResult(NamedTuple): scan_id: str report_url: Optional[str] - document_detections: List[DocumentDetections] + document_detections: list[DocumentDetections] issue_detected: bool detections_count: int relevant_detections_count: int @@ -66,8 +66,8 @@ class ResourceChange: resource_type: str name: str index: Optional[int] - actions: List[str] - values: Dict[str, str] + actions: list[str] + values: dict[str, str] def __repr__(self) -> str: return f'resource_type: {self.resource_type}, name: {self.name}' diff --git a/cycode/cli/printers/console_printer.py b/cycode/cli/printers/console_printer.py index 00eb38cf..f581c894 100644 --- a/cycode/cli/printers/console_printer.py +++ b/cycode/cli/printers/console_printer.py @@ -1,5 +1,5 @@ import io -from typing import TYPE_CHECKING, ClassVar, Dict, List, Optional, Type +from typing import TYPE_CHECKING, ClassVar, Optional import typer from rich.console import Console @@ -21,7 +21,7 @@ class ConsolePrinter: - _AVAILABLE_PRINTERS: ClassVar[Dict[str, Type['PrinterBase']]] = { + _AVAILABLE_PRINTERS: ClassVar[dict[str, type['PrinterBase']]] = { 'rich': RichPrinter, 'text': TextPrinter, 'json': JsonPrinter, @@ -78,8 +78,8 @@ def printer(self) -> 'PrinterBase': def print_scan_results( self, - local_scan_results: List['LocalScanResult'], - errors: Optional[Dict[str, 'CliError']] = None, + local_scan_results: list['LocalScanResult'], + errors: Optional[dict[str, 'CliError']] = None, ) -> None: if self.console_record: self.console_record.print_scan_results(local_scan_results, errors) diff --git a/cycode/cli/printers/json_printer.py b/cycode/cli/printers/json_printer.py index 6ad14e22..acb7912f 100644 --- a/cycode/cli/printers/json_printer.py +++ b/cycode/cli/printers/json_printer.py @@ -1,5 +1,5 @@ import json -from typing import TYPE_CHECKING, Dict, List, Optional +from typing import TYPE_CHECKING, Optional from cycode.cli.models import CliError, CliResult from cycode.cli.printers.printer_base import PrinterBase @@ -21,7 +21,7 @@ def print_error(self, error: CliError) -> None: self.console.print_json(self.get_data_json(result)) def print_scan_results( - self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None + self, local_scan_results: list['LocalScanResult'], errors: Optional[dict[str, 'CliError']] = None ) -> None: scan_ids = [] report_urls = [] @@ -48,7 +48,7 @@ def print_scan_results( self.console.print_json(self._get_json_scan_result(scan_ids, detections_dict, report_urls, inlined_errors)) def _get_json_scan_result( - self, scan_ids: List[str], detections: dict, report_urls: List[str], errors: List[dict] + self, scan_ids: list[str], detections: dict, report_urls: list[str], errors: list[dict] ) -> str: result = { 'scan_ids': scan_ids, diff --git a/cycode/cli/printers/printer_base.py b/cycode/cli/printers/printer_base.py index 23ba7384..527cc31b 100644 --- a/cycode/cli/printers/printer_base.py +++ b/cycode/cli/printers/printer_base.py @@ -1,7 +1,7 @@ import sys from abc import ABC, abstractmethod from collections import defaultdict -from typing import TYPE_CHECKING, Dict, List, Optional +from typing import TYPE_CHECKING, Optional import typer @@ -51,7 +51,7 @@ def show_secret(self) -> bool: @abstractmethod def print_scan_results( - self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None + self, local_scan_results: list['LocalScanResult'], errors: Optional[dict[str, 'CliError']] = None ) -> None: pass @@ -68,6 +68,7 @@ def print_exception(self, e: Optional[BaseException] = None) -> None: Note: Called only when the verbose flag is set. + """ rich_traceback = ( RichTraceback.from_exception(type(e), e, e.__traceback__) @@ -79,7 +80,7 @@ def print_exception(self, e: Optional[BaseException] = None) -> None: self.console_err.print(f'[red]Correlation ID:[/] {get_correlation_id()}') - def print_scan_results_summary(self, local_scan_results: List['LocalScanResult']) -> None: + def print_scan_results_summary(self, local_scan_results: list['LocalScanResult']) -> None: """Print a summary of scan results based on severity levels. Args: @@ -87,8 +88,8 @@ def print_scan_results_summary(self, local_scan_results: List['LocalScanResult'] The summary includes the count of detections for each severity level and is displayed in the console in a formatted string. - """ + """ detections_count = 0 severity_counts = defaultdict(int) for local_scan_result in local_scan_results: diff --git a/cycode/cli/printers/rich_printer.py b/cycode/cli/printers/rich_printer.py index 3401b8f5..b2ed1a2e 100644 --- a/cycode/cli/printers/rich_printer.py +++ b/cycode/cli/printers/rich_printer.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Dict, List, Optional +from typing import TYPE_CHECKING, Optional from rich.console import Group from rich.panel import Panel @@ -25,7 +25,7 @@ class RichPrinter(TextPrinter): MAX_PATH_LENGTH = 60 def print_scan_results( - self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None + self, local_scan_results: list['LocalScanResult'], errors: Optional[dict[str, 'CliError']] = None ) -> None: if not errors and all(result.issue_detected == 0 for result in local_scan_results): self.console.print(self.NO_DETECTIONS_MESSAGE) @@ -57,7 +57,7 @@ def _get_details_table(self, detection: 'Detection') -> Table: detection_details = detection.detection_details path = str(get_detection_file_path(self.scan_type, detection)) - shorten_path = f'...{path[-self.MAX_PATH_LENGTH:]}' if len(path) > self.MAX_PATH_LENGTH else path + shorten_path = f'...{path[-self.MAX_PATH_LENGTH :]}' if len(path) > self.MAX_PATH_LENGTH else path details_table.add_row('In file', f'[link=file://{path}]{shorten_path}[/]') if self.scan_type == consts.SECRET_SCAN_TYPE: diff --git a/cycode/cli/printers/tables/sca_table_printer.py b/cycode/cli/printers/tables/sca_table_printer.py index 74ac2832..0bf59a20 100644 --- a/cycode/cli/printers/tables/sca_table_printer.py +++ b/cycode/cli/printers/tables/sca_table_printer.py @@ -1,5 +1,5 @@ from collections import defaultdict -from typing import TYPE_CHECKING, Dict, List +from typing import TYPE_CHECKING from cycode.cli.cli_types import SeverityOption from cycode.cli.consts import LICENSE_COMPLIANCE_POLICY_ID, PACKAGE_VULNERABILITY_POLICY_ID @@ -30,7 +30,7 @@ class ScaTablePrinter(TablePrinterBase): - def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: + def _print_results(self, local_scan_results: list['LocalScanResult']) -> None: aggregation_report_url = self.ctx.obj.get('aggregation_report_url') detections_per_policy_id = self._extract_detections_per_policy_id(local_scan_results) for policy_id, detections in detections_per_policy_id.items(): @@ -83,14 +83,8 @@ def _get_table(self, policy_id: str) -> Table: def _enrich_table_with_values(policy_id: str, table: Table, detection: Detection) -> None: detection_details = detection.detection_details - severity = None - if policy_id == PACKAGE_VULNERABILITY_POLICY_ID: - severity = detection_details.get('advisory_severity') - elif policy_id == LICENSE_COMPLIANCE_POLICY_ID: - severity = detection.severity - - if severity: - table.add_cell(SEVERITY_COLUMN, SeverityOption(severity)) + if detection.severity: + table.add_cell(SEVERITY_COLUMN, SeverityOption(detection.severity)) else: table.add_cell(SEVERITY_COLUMN, 'N/A') @@ -134,8 +128,8 @@ def _print_summary_issues(self, detections_count: int, title: str) -> None: @staticmethod def _extract_detections_per_policy_id( - local_scan_results: List['LocalScanResult'], - ) -> Dict[str, List[Detection]]: + local_scan_results: list['LocalScanResult'], + ) -> dict[str, list[Detection]]: detections_to_policy_id = defaultdict(list) for local_scan_result in local_scan_results: diff --git a/cycode/cli/printers/tables/table.py b/cycode/cli/printers/tables/table.py index b89df4af..61e143ca 100644 --- a/cycode/cli/printers/tables/table.py +++ b/cycode/cli/printers/tables/table.py @@ -1,5 +1,5 @@ import urllib.parse -from typing import TYPE_CHECKING, Dict, List, Optional, Set +from typing import TYPE_CHECKING, Optional from rich.markup import escape from rich.table import Table as RichTable @@ -11,10 +11,10 @@ class Table: """Helper class to manage columns and their values in the right order and only if the column should be presented.""" - def __init__(self, column_infos: Optional[List['ColumnInfo']] = None) -> None: - self._group_separator_indexes: Set[int] = set() + def __init__(self, column_infos: Optional[list['ColumnInfo']] = None) -> None: + self._group_separator_indexes: set[int] = set() - self._columns: Dict['ColumnInfo', List[str]] = {} + self._columns: dict[ColumnInfo, list[str]] = {} if column_infos: self._columns = {columns: [] for columns in column_infos} @@ -37,17 +37,17 @@ def add_file_path_cell(self, column: 'ColumnInfo', path: str) -> None: escaped_path = escape(encoded_path) self._add_cell_no_error(column, f'[link file://{escaped_path}]{path}') - def set_group_separator_indexes(self, group_separator_indexes: Set[int]) -> None: + def set_group_separator_indexes(self, group_separator_indexes: set[int]) -> None: self._group_separator_indexes = group_separator_indexes - def _get_ordered_columns(self) -> List['ColumnInfo']: + def _get_ordered_columns(self) -> list['ColumnInfo']: # we are sorting columns by index to make sure that columns will be printed in the right order return sorted(self._columns, key=lambda column_info: column_info.index) - def get_columns_info(self) -> List['ColumnInfo']: + def get_columns_info(self) -> list['ColumnInfo']: return self._get_ordered_columns() - def get_rows(self) -> List[str]: + def get_rows(self) -> list[str]: column_values = [self._columns[column_info] for column_info in self._get_ordered_columns()] return list(zip(*column_values)) diff --git a/cycode/cli/printers/tables/table_models.py b/cycode/cli/printers/tables/table_models.py index 42e3b1fb..58e41aaa 100644 --- a/cycode/cli/printers/tables/table_models.py +++ b/cycode/cli/printers/tables/table_models.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, NamedTuple, Optional +from typing import NamedTuple, Optional class ColumnInfoBuilder: @@ -14,12 +14,12 @@ def build(self, name: str, **column_opts) -> 'ColumnInfo': class ColumnInfo(NamedTuple): name: str index: int # Represents the order of the columns, starting from the left - column_opts: Optional[Dict] = None + column_opts: Optional[dict] = None def __hash__(self) -> int: return hash((self.name, self.index)) - def __eq__(self, other: Any) -> bool: + def __eq__(self, other: object) -> bool: if not isinstance(other, ColumnInfo): return NotImplemented return (self.name, self.index) == (other.name, other.index) diff --git a/cycode/cli/printers/tables/table_printer.py b/cycode/cli/printers/tables/table_printer.py index 4f821c7f..fe9f8dd5 100644 --- a/cycode/cli/printers/tables/table_printer.py +++ b/cycode/cli/printers/tables/table_printer.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING from cycode.cli.cli_types import SeverityOption from cycode.cli.consts import SECRET_SCAN_TYPE @@ -27,7 +27,7 @@ class TablePrinter(TablePrinterBase): - def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: + def _print_results(self, local_scan_results: list['LocalScanResult']) -> None: table = self._get_table() detections, group_separator_indexes = sort_and_group_detections_from_scan_result(local_scan_results) diff --git a/cycode/cli/printers/tables/table_printer_base.py b/cycode/cli/printers/tables/table_printer_base.py index 5d2aaa73..d7a2b502 100644 --- a/cycode/cli/printers/tables/table_printer_base.py +++ b/cycode/cli/printers/tables/table_printer_base.py @@ -1,5 +1,5 @@ import abc -from typing import TYPE_CHECKING, Dict, List, Optional +from typing import TYPE_CHECKING, Optional from cycode.cli.models import CliError, CliResult from cycode.cli.printers.printer_base import PrinterBase @@ -18,7 +18,7 @@ def print_error(self, error: CliError) -> None: TextPrinter(self.ctx, self.console, self.console_err).print_error(error) def print_scan_results( - self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None + self, local_scan_results: list['LocalScanResult'], errors: Optional[dict[str, 'CliError']] = None ) -> None: if not errors and all(result.issue_detected == 0 for result in local_scan_results): self.console.print(self.NO_DETECTIONS_MESSAGE) @@ -38,7 +38,7 @@ def _is_git_repository(self) -> bool: return self.ctx.info_name in {'commit_history', 'pre_commit', 'pre_receive'} and 'remote_url' in self.ctx.obj @abc.abstractmethod - def _print_results(self, local_scan_results: List['LocalScanResult']) -> None: + def _print_results(self, local_scan_results: list['LocalScanResult']) -> None: raise NotImplementedError def _print_table(self, table: 'Table') -> None: @@ -47,7 +47,7 @@ def _print_table(self, table: 'Table') -> None: def _print_report_urls( self, - local_scan_results: List['LocalScanResult'], + local_scan_results: list['LocalScanResult'], aggregation_report_url: Optional[str] = None, ) -> None: report_urls = [scan_result.report_url for scan_result in local_scan_results if scan_result.report_url] diff --git a/cycode/cli/printers/text_printer.py b/cycode/cli/printers/text_printer.py index 6eb4b78b..564456ae 100644 --- a/cycode/cli/printers/text_printer.py +++ b/cycode/cli/printers/text_printer.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Dict, List, Optional +from typing import TYPE_CHECKING, Optional from cycode.cli.cli_types import SeverityOption from cycode.cli.models import CliError, CliResult, Document @@ -30,7 +30,7 @@ def print_error(self, error: CliError) -> None: self.console.print(f'[red]Error: {error.message}[/]', highlight=False) def print_scan_results( - self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None + self, local_scan_results: list['LocalScanResult'], errors: Optional[dict[str, 'CliError']] = None ) -> None: if not errors and all(result.issue_detected == 0 for result in local_scan_results): self.console.print(self.NO_DETECTIONS_MESSAGE) @@ -82,7 +82,7 @@ def __print_detection_code_segment(self, detection: 'Detection', document: Docum ) def print_report_urls_and_errors( - self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None + self, local_scan_results: list['LocalScanResult'], errors: Optional[dict[str, 'CliError']] = None ) -> None: report_urls = [scan_result.report_url for scan_result in local_scan_results if scan_result.report_url] @@ -95,7 +95,7 @@ def print_report_urls_and_errors( self.console.print(f'- {scan_id}: ', end='') self.print_error(error) - def print_report_urls(self, report_urls: List[str], aggregation_report_url: Optional[str] = None) -> None: + def print_report_urls(self, report_urls: list[str], aggregation_report_url: Optional[str] = None) -> None: if not report_urls and not aggregation_report_url: return if aggregation_report_url: diff --git a/cycode/cli/printers/utils/detection_ordering/common_ordering.py b/cycode/cli/printers/utils/detection_ordering/common_ordering.py index d93b858e..c4b431ef 100644 --- a/cycode/cli/printers/utils/detection_ordering/common_ordering.py +++ b/cycode/cli/printers/utils/detection_ordering/common_ordering.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, List, Set, Tuple +from typing import TYPE_CHECKING from cycode.cli.cli_types import SeverityOption @@ -7,36 +7,36 @@ from cycode.cyclient.models import Detection -GroupedDetections = Tuple[List[Tuple['Detection', 'Document']], Set[int]] +GroupedDetections = tuple[list[tuple['Detection', 'Document']], set[int]] -def __severity_sort_key(detection_with_document: Tuple['Detection', 'Document']) -> int: +def __severity_sort_key(detection_with_document: tuple['Detection', 'Document']) -> int: detection, _ = detection_with_document severity = detection.severity if detection.severity else '' return SeverityOption.get_member_weight(severity) def _sort_detections_by_severity( - detections_with_documents: List[Tuple['Detection', 'Document']], -) -> List[Tuple['Detection', 'Document']]: + detections_with_documents: list[tuple['Detection', 'Document']], +) -> list[tuple['Detection', 'Document']]: return sorted(detections_with_documents, key=__severity_sort_key, reverse=True) -def __file_path_sort_key(detection_with_document: Tuple['Detection', 'Document']) -> str: +def __file_path_sort_key(detection_with_document: tuple['Detection', 'Document']) -> str: _, document = detection_with_document return document.path def _sort_detections_by_file_path( - detections_with_documents: List[Tuple['Detection', 'Document']], -) -> List[Tuple['Detection', 'Document']]: + detections_with_documents: list[tuple['Detection', 'Document']], +) -> list[tuple['Detection', 'Document']]: return sorted(detections_with_documents, key=__file_path_sort_key) def sort_and_group_detections( - detections_with_documents: List[Tuple['Detection', 'Document']], + detections_with_documents: list[tuple['Detection', 'Document']], ) -> GroupedDetections: - """Sort detections by severity. We do not have groping here (don't find the best one yet).""" + """Sort detections by severity. We do not have grouping here (don't find the best one yet).""" group_separator_indexes = set() # we sort detections by file path to make persist output order @@ -46,7 +46,7 @@ def sort_and_group_detections( return sorted_by_severity, group_separator_indexes -def sort_and_group_detections_from_scan_result(local_scan_results: List['LocalScanResult']) -> GroupedDetections: +def sort_and_group_detections_from_scan_result(local_scan_results: list['LocalScanResult']) -> GroupedDetections: detections_with_documents = [] for local_scan_result in local_scan_results: for document_detections in local_scan_result.document_detections: diff --git a/cycode/cli/printers/utils/detection_ordering/sca_ordering.py b/cycode/cli/printers/utils/detection_ordering/sca_ordering.py index 85915c56..a8be3430 100644 --- a/cycode/cli/printers/utils/detection_ordering/sca_ordering.py +++ b/cycode/cli/printers/utils/detection_ordering/sca_ordering.py @@ -1,5 +1,5 @@ from collections import defaultdict -from typing import TYPE_CHECKING, Dict, List, Set, Tuple +from typing import TYPE_CHECKING from cycode.cli.cli_types import SeverityOption @@ -7,7 +7,7 @@ from cycode.cyclient.models import Detection -def __group_by(detections: List['Detection'], details_field_name: str) -> Dict[str, List['Detection']]: +def __group_by(detections: list['Detection'], details_field_name: str) -> dict[str, list['Detection']]: grouped = defaultdict(list) for detection in detections: grouped[detection.detection_details.get(details_field_name)].append(detection) @@ -15,11 +15,11 @@ def __group_by(detections: List['Detection'], details_field_name: str) -> Dict[s def __severity_sort_key(detection: 'Detection') -> int: - severity = detection.detection_details.get('advisory_severity', 'unknown') + severity = detection.severity if detection.severity else 'unknown' return SeverityOption.get_member_weight(severity) -def _sort_detections_by_severity(detections: List['Detection']) -> List['Detection']: +def _sort_detections_by_severity(detections: list['Detection']) -> list['Detection']: return sorted(detections, key=__severity_sort_key, reverse=True) @@ -27,11 +27,11 @@ def __package_sort_key(detection: 'Detection') -> int: return detection.detection_details.get('package_name') -def _sort_detections_by_package(detections: List['Detection']) -> List['Detection']: +def _sort_detections_by_package(detections: list['Detection']) -> list['Detection']: return sorted(detections, key=__package_sort_key) -def sort_and_group_detections(detections: List['Detection']) -> Tuple[List['Detection'], Set[int]]: +def sort_and_group_detections(detections: list['Detection']) -> tuple[list['Detection'], set[int]]: """Sort detections by severity and group by repository, code project and package name. Note: @@ -39,6 +39,7 @@ def sort_and_group_detections(detections: List['Detection']) -> Tuple[List['Dete Grouping by code projects also groups by ecosystem. Because manifest files are unique per ecosystem. + """ resulting_detections = [] group_separator_indexes = set() diff --git a/cycode/cli/user_settings/base_file_manager.py b/cycode/cli/user_settings/base_file_manager.py index 4eb15e2a..4f07f11c 100644 --- a/cycode/cli/user_settings/base_file_manager.py +++ b/cycode/cli/user_settings/base_file_manager.py @@ -1,6 +1,7 @@ import os from abc import ABC, abstractmethod -from typing import Any, Dict, Hashable +from collections.abc import Hashable +from typing import Any from cycode.cli.utils.yaml_utils import read_yaml_file, update_yaml_file @@ -9,10 +10,10 @@ class BaseFileManager(ABC): @abstractmethod def get_filename(self) -> str: ... - def read_file(self) -> Dict[Hashable, Any]: + def read_file(self) -> dict[Hashable, Any]: return read_yaml_file(self.get_filename()) - def write_content_to_file(self, content: Dict[Hashable, Any]) -> None: + def write_content_to_file(self, content: dict[Hashable, Any]) -> None: filename = self.get_filename() os.makedirs(os.path.dirname(filename), exist_ok=True) update_yaml_file(filename, content) diff --git a/cycode/cli/user_settings/config_file_manager.py b/cycode/cli/user_settings/config_file_manager.py index e4e5e6b1..5b029e39 100644 --- a/cycode/cli/user_settings/config_file_manager.py +++ b/cycode/cli/user_settings/config_file_manager.py @@ -1,5 +1,6 @@ import os -from typing import TYPE_CHECKING, Any, Dict, Hashable, List, Optional, Union +from collections.abc import Hashable +from typing import TYPE_CHECKING, Any, Optional, Union from cycode.cli.consts import CYCODE_CONFIGURATION_DIRECTORY from cycode.cli.user_settings.base_file_manager import BaseFileManager @@ -37,7 +38,7 @@ def get_app_url(self) -> Optional[Any]: def get_verbose_flag(self) -> Optional[Any]: return self._get_value_from_environment_section(self.VERBOSE_FIELD_NAME) - def get_exclusions_by_scan_type(self, scan_type: str) -> Dict[Hashable, Any]: + def get_exclusions_by_scan_type(self, scan_type: str) -> dict[Hashable, Any]: exclusions_section = self._get_section(self.EXCLUSIONS_SECTION_NAME) return exclusions_section.get(scan_type, {}) @@ -87,7 +88,7 @@ def get_filename(self) -> str: def get_config_file_route() -> str: return os.path.join(ConfigFileManager.CYCODE_HIDDEN_DIRECTORY, ConfigFileManager.FILE_NAME) - def _get_exclusions_by_exclusion_type(self, scan_type: str, exclusion_type: str) -> List[Any]: + def _get_exclusions_by_exclusion_type(self, scan_type: str, exclusion_type: str) -> list[Any]: scan_type_exclusions = self.get_exclusions_by_scan_type(scan_type) return scan_type_exclusions.get(exclusion_type, []) @@ -95,7 +96,7 @@ def _get_value_from_environment_section(self, field_name: str) -> Optional[Any]: environment_section = self._get_section(self.ENVIRONMENT_SECTION_NAME) return environment_section.get(field_name) - def _get_scan_configuration_by_scan_type(self, command_scan_type: str) -> Dict[Hashable, Any]: + def _get_scan_configuration_by_scan_type(self, command_scan_type: str) -> dict[Hashable, Any]: scan_section = self._get_section(self.SCAN_SECTION_NAME) return scan_section.get(command_scan_type, {}) @@ -103,6 +104,6 @@ def _get_value_from_command_scan_type_configuration(self, command_scan_type: str command_scan_type_configuration = self._get_scan_configuration_by_scan_type(command_scan_type) return command_scan_type_configuration.get(field_name) - def _get_section(self, section_name: str) -> Dict[Hashable, Any]: + def _get_section(self, section_name: str) -> dict[Hashable, Any]: file_content = self.read_file() return file_content.get(section_name, {}) diff --git a/cycode/cli/user_settings/configuration_manager.py b/cycode/cli/user_settings/configuration_manager.py index f8d67c42..3b83f1c9 100644 --- a/cycode/cli/user_settings/configuration_manager.py +++ b/cycode/cli/user_settings/configuration_manager.py @@ -1,7 +1,7 @@ import os -from functools import lru_cache +from functools import cache from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Optional from uuid import uuid4 from cycode.cli import consts @@ -69,8 +69,8 @@ def get_verbose_flag_from_environment_variables(self) -> bool: value = self._get_value_from_environment_variables(consts.VERBOSE_ENV_VAR_NAME, '') return value.lower() in ('true', '1') - @lru_cache(maxsize=None) # noqa: B019 - def get_exclusions_by_scan_type(self, scan_type: str) -> Dict: + @cache # noqa: B019 + def get_exclusions_by_scan_type(self, scan_type: str) -> dict: local_exclusions = self.local_config_file_manager.get_exclusions_by_scan_type(scan_type) global_exclusions = self.global_config_file_manager.get_exclusions_by_scan_type(scan_type) return self._merge_exclusions(local_exclusions, global_exclusions) @@ -80,7 +80,7 @@ def add_exclusion(self, scope: str, scan_type: str, exclusion_type: str, value: config_file_manager.add_exclusion(scan_type, exclusion_type, value) @staticmethod - def _merge_exclusions(local_exclusions: Dict, global_exclusions: Dict) -> Dict: + def _merge_exclusions(local_exclusions: dict, global_exclusions: dict) -> dict: keys = set(list(local_exclusions.keys()) + list(global_exclusions.keys())) return {key: local_exclusions.get(key, []) + global_exclusions.get(key, []) for key in keys} diff --git a/cycode/cli/user_settings/credentials_manager.py b/cycode/cli/user_settings/credentials_manager.py index 86a84ba6..7af43569 100644 --- a/cycode/cli/user_settings/credentials_manager.py +++ b/cycode/cli/user_settings/credentials_manager.py @@ -1,6 +1,6 @@ import os from pathlib import Path -from typing import Optional, Tuple +from typing import Optional from cycode.cli.config import CYCODE_CLIENT_ID_ENV_VAR_NAME, CYCODE_CLIENT_SECRET_ENV_VAR_NAME from cycode.cli.user_settings.base_file_manager import BaseFileManager @@ -19,7 +19,7 @@ class CredentialsManager(BaseFileManager): ACCESS_TOKEN_EXPIRES_IN_FIELD_NAME: str = 'cycode_access_token_expires_in' ACCESS_TOKEN_CREATOR_FIELD_NAME: str = 'cycode_access_token_creator' - def get_credentials(self) -> Tuple[str, str]: + def get_credentials(self) -> tuple[str, str]: client_id, client_secret = self.get_credentials_from_environment_variables() if client_id is not None and client_secret is not None: return client_id, client_secret @@ -27,12 +27,12 @@ def get_credentials(self) -> Tuple[str, str]: return self.get_credentials_from_file() @staticmethod - def get_credentials_from_environment_variables() -> Tuple[str, str]: + def get_credentials_from_environment_variables() -> tuple[str, str]: client_id = os.getenv(CYCODE_CLIENT_ID_ENV_VAR_NAME) client_secret = os.getenv(CYCODE_CLIENT_SECRET_ENV_VAR_NAME) return client_id, client_secret - def get_credentials_from_file(self) -> Tuple[Optional[str], Optional[str]]: + def get_credentials_from_file(self) -> tuple[Optional[str], Optional[str]]: file_content = self.read_file() client_id = file_content.get(self.CLIENT_ID_FIELD_NAME) client_secret = file_content.get(self.CLIENT_SECRET_FIELD_NAME) @@ -42,7 +42,7 @@ def update_credentials(self, client_id: str, client_secret: str) -> None: file_content_to_update = {self.CLIENT_ID_FIELD_NAME: client_id, self.CLIENT_SECRET_FIELD_NAME: client_secret} self.write_content_to_file(file_content_to_update) - def get_access_token(self) -> Tuple[Optional[str], Optional[float], Optional[JwtCreator]]: + def get_access_token(self) -> tuple[Optional[str], Optional[float], Optional[JwtCreator]]: file_content = self.read_file() access_token = file_content.get(self.ACCESS_TOKEN_FIELD_NAME) diff --git a/cycode/cli/utils/enum_utils.py b/cycode/cli/utils/enum_utils.py index 6ea9ef72..3280a5bb 100644 --- a/cycode/cli/utils/enum_utils.py +++ b/cycode/cli/utils/enum_utils.py @@ -1,8 +1,7 @@ from enum import Enum -from typing import List class AutoCountEnum(Enum): @staticmethod - def _generate_next_value_(name: str, start: int, count: int, last_values: List[int]) -> int: + def _generate_next_value_(name: str, start: int, count: int, last_values: list[int]) -> int: return count diff --git a/cycode/cli/utils/get_api_client.py b/cycode/cli/utils/get_api_client.py index 7bbfa2d9..91e8f0f7 100644 --- a/cycode/cli/utils/get_api_client.py +++ b/cycode/cli/utils/get_api_client.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Optional, Tuple, Union +from typing import TYPE_CHECKING, Optional, Union import click @@ -35,6 +35,6 @@ def get_report_cycode_client( return _get_cycode_client(create_report_client, client_id, client_secret, hide_response_log) -def _get_configured_credentials() -> Tuple[str, str]: +def _get_configured_credentials() -> tuple[str, str]: credentials_manager = CredentialsManager() return credentials_manager.get_credentials() diff --git a/cycode/cli/utils/git_proxy.py b/cycode/cli/utils/git_proxy.py index c46d016b..beaafdd0 100644 --- a/cycode/cli/utils/git_proxy.py +++ b/cycode/cli/utils/git_proxy.py @@ -1,9 +1,9 @@ import types from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Optional, Type +from typing import TYPE_CHECKING, Optional _GIT_ERROR_MESSAGE = """ -Cycode CLI needs the git executable to be installed on the system. +Cycode CLI needs the Git executable to be installed on the system. Git executable must be available in the PATH. Git 1.7.x or newer is required. You can help Cycode CLI to locate the Git executable @@ -31,10 +31,10 @@ def get_repo(self, path: Optional['PathLike'] = None, *args, **kwargs) -> 'Repo' def get_null_tree(self) -> object: ... @abstractmethod - def get_invalid_git_repository_error(self) -> Type[BaseException]: ... + def get_invalid_git_repository_error(self) -> type[BaseException]: ... @abstractmethod - def get_git_command_error(self) -> Type[BaseException]: ... + def get_git_command_error(self) -> type[BaseException]: ... class _DummyGitProxy(_AbstractGitProxy): @@ -44,10 +44,10 @@ def get_repo(self, path: Optional['PathLike'] = None, *args, **kwargs) -> 'Repo' def get_null_tree(self) -> object: raise RuntimeError(_GIT_ERROR_MESSAGE) - def get_invalid_git_repository_error(self) -> Type[BaseException]: + def get_invalid_git_repository_error(self) -> type[BaseException]: return GitProxyError - def get_git_command_error(self) -> Type[BaseException]: + def get_git_command_error(self) -> type[BaseException]: return GitProxyError @@ -58,10 +58,10 @@ def get_repo(self, path: Optional['PathLike'] = None, *args, **kwargs) -> 'Repo' def get_null_tree(self) -> object: return git.NULL_TREE - def get_invalid_git_repository_error(self) -> Type[BaseException]: + def get_invalid_git_repository_error(self) -> type[BaseException]: return git.InvalidGitRepositoryError - def get_git_command_error(self) -> Type[BaseException]: + def get_git_command_error(self) -> type[BaseException]: return git.GitCommandError @@ -87,10 +87,10 @@ def get_repo(self, path: Optional['PathLike'] = None, *args, **kwargs) -> 'Repo' def get_null_tree(self) -> object: return self._git_proxy.get_null_tree() - def get_invalid_git_repository_error(self) -> Type[BaseException]: + def get_invalid_git_repository_error(self) -> type[BaseException]: return self._git_proxy.get_invalid_git_repository_error() - def get_git_command_error(self) -> Type[BaseException]: + def get_git_command_error(self) -> type[BaseException]: return self._git_proxy.get_git_command_error() diff --git a/cycode/cli/utils/ignore_utils.py b/cycode/cli/utils/ignore_utils.py index f44b6024..e8994e46 100644 --- a/cycode/cli/utils/ignore_utils.py +++ b/cycode/cli/utils/ignore_utils.py @@ -38,16 +38,12 @@ import contextlib import os.path import re +from collections.abc import Generator, Iterable from os import PathLike from typing import ( Any, BinaryIO, - Dict, - Generator, - Iterable, - List, Optional, - Tuple, Union, ) @@ -98,7 +94,6 @@ def translate(pat: bytes) -> bytes: Originally copied from fnmatch in Python 2.7, but modified for Dulwich to cope with features in Git ignore patterns. """ - res = b'(?ms)' if b'/' not in pat[:-1]: @@ -131,6 +126,7 @@ def read_ignore_patterns(f: BinaryIO) -> Iterable[bytes]: Args: f: File-like object to read from Returns: List of patterns + """ for line in f: line = line.rstrip(b'\r\n') @@ -160,6 +156,7 @@ def match_pattern(path: bytes, pattern: bytes, ignore_case: bool = False) -> boo ignore_case: Whether to do case-sensitive matching Returns: bool indicating whether the pattern matched + """ return Pattern(pattern, ignore_case).match(path) @@ -200,6 +197,7 @@ def match(self, path: bytes) -> bool: Args: path: Path to match (relative to ignore location) Returns: boolean + """ return bool(self._re.match(path)) @@ -219,7 +217,7 @@ def __init__( for pattern in patterns: self.append_pattern(pattern) - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: d = { 'patterns': [str(p) for p in self._patterns], 'ignore_case': self._ignore_case, @@ -242,6 +240,7 @@ def find_matching(self, path: Union[bytes, str]) -> Iterable[Pattern]: path: Path to match Returns: Iterator over iterators + """ if not isinstance(path, bytes): path = os.fsencode(path) @@ -284,7 +283,7 @@ class IgnoreFilterManager: def __init__( self, path: str, - global_filters: List[IgnoreFilter], + global_filters: list[IgnoreFilter], ignore_file_name: Optional[str] = None, ignore_case: bool = False, ) -> None: @@ -303,7 +302,7 @@ def __init__( def __repr__(self) -> str: return f'{type(self).__name__}({self._top_path}, {self._global_filters!r}, {self._ignore_case!r})' - def to_dict(self, include_path_filters: bool = True) -> Dict[str, Any]: + def to_dict(self, include_path_filters: bool = True) -> dict[str, Any]: d = { 'path': self._top_path, 'global_filters': [f.to_dict() for f in self._global_filters], @@ -337,7 +336,7 @@ def _load_path(self, path: str) -> Optional[IgnoreFilter]: p = os.path.join(self._top_path, path, self._ignore_file_name) try: self._path_filters[path] = IgnoreFilter.from_path(p, self._ignore_case) - except IOError: + except OSError: self._path_filters[path] = None return self._path_filters[path] @@ -348,6 +347,7 @@ def _find_matching(self, path: str) -> Iterable[Pattern]: path: Path to check Returns: Iterator over Pattern instances + """ if os.path.isabs(path): raise ValueError(f'{path} is an absolute path') @@ -379,6 +379,7 @@ def is_ignored(self, path: str) -> Optional[bool]: True if the path matches an ignore pattern, False if the path is explicitly not ignored, or None if the file does not match any patterns. + """ if hasattr(path, '__fspath__'): path = path.__fspath__() @@ -387,10 +388,8 @@ def is_ignored(self, path: str) -> Optional[bool]: return matches[-1].is_exclude return None - def walk(self, **kwargs) -> Generator[Tuple[str, List[str], List[str]], None, None]: - """A wrapper for os.walk() without ignored files and subdirectories. - kwargs are passed to walk().""" - + def walk(self, **kwargs) -> Generator[tuple[str, list[str], list[str]], None, None]: + """Wrap os.walk() without ignored files and subdirectories and kwargs are passed to walk.""" for dirpath, dirnames, filenames in os.walk(self.path, topdown=True, **kwargs): rel_dirpath = '' if dirpath == self.path else os.path.relpath(dirpath, self.path) @@ -413,6 +412,7 @@ def build( ignore_case: bool = False, ) -> 'IgnoreFilterManager': """Create a IgnoreFilterManager from patterns and paths. + Args: path: The root path for ignore checks. global_ignore_file_paths: A list of file paths to load patterns from. @@ -421,8 +421,10 @@ def build( global_patterns: Global patterns to ignore. ignore_file_name: The per-directory ignore file name. ignore_case: Whether to ignore case in matching. + Returns: A `IgnoreFilterManager` object + """ if not global_ignore_file_paths: global_ignore_file_paths = [] diff --git a/cycode/cli/utils/jwt_utils.py b/cycode/cli/utils/jwt_utils.py index 7bb7df62..c87b7c48 100644 --- a/cycode/cli/utils/jwt_utils.py +++ b/cycode/cli/utils/jwt_utils.py @@ -1,11 +1,11 @@ -from typing import Optional, Tuple +from typing import Optional import jwt _JWT_PAYLOAD_POSSIBLE_USER_ID_FIELD_NAMES = ('userId', 'internalId', 'token-user-id') -def get_user_and_tenant_ids_from_access_token(access_token: str) -> Tuple[Optional[str], Optional[str]]: +def get_user_and_tenant_ids_from_access_token(access_token: str) -> tuple[Optional[str], Optional[str]]: payload = jwt.decode(access_token, options={'verify_signature': False}) user_id = None diff --git a/cycode/cli/utils/path_utils.py b/cycode/cli/utils/path_utils.py index 3f670dd4..7d525e56 100644 --- a/cycode/cli/utils/path_utils.py +++ b/cycode/cli/utils/path_utils.py @@ -1,7 +1,7 @@ import json import os -from functools import lru_cache -from typing import TYPE_CHECKING, AnyStr, List, Optional, Union +from functools import cache +from typing import TYPE_CHECKING, AnyStr, Optional, Union import typer from binaryornot.helpers import is_binary_string @@ -12,7 +12,7 @@ from os import PathLike -@lru_cache(maxsize=None) +@cache def is_sub_path(path: str, sub_path: str) -> bool: try: common_path = os.path.commonpath([get_absolute_path(path), get_absolute_path(sub_path)]) @@ -35,7 +35,7 @@ def _get_starting_chunk(filename: str, length: int = 1024) -> Optional[bytes]: try: with open(filename, 'rb') as f: return f.read(length) - except IOError as e: + except OSError as e: logger.debug('Failed to read the starting chunk from file: %s', filename, exc_info=e) return None @@ -68,7 +68,7 @@ def get_file_dir(path: str) -> str: return os.path.dirname(path) -def get_immediate_subdirectories(path: str) -> List[str]: +def get_immediate_subdirectories(path: str) -> list[str]: return [f.name for f in os.scandir(path) if f.is_dir()] @@ -78,7 +78,7 @@ def join_paths(path: str, filename: str) -> str: def get_file_content(file_path: Union[str, 'PathLike']) -> Optional[AnyStr]: try: - with open(file_path, 'r', encoding='UTF-8') as f: + with open(file_path, encoding='UTF-8') as f: return f.read() except (FileNotFoundError, UnicodeDecodeError): return None diff --git a/cycode/cli/utils/progress_bar.py b/cycode/cli/utils/progress_bar.py index 054d5cf8..7c2de487 100644 --- a/cycode/cli/utils/progress_bar.py +++ b/cycode/cli/utils/progress_bar.py @@ -1,6 +1,6 @@ from abc import ABC, abstractmethod from enum import auto -from typing import Dict, NamedTuple, Optional +from typing import NamedTuple, Optional from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn, TimeElapsedColumn @@ -38,7 +38,7 @@ class ProgressBarSectionInfo(NamedTuple): TimeElapsedColumn(), ) -ProgressBarSections = Dict[ProgressBarSection, ProgressBarSectionInfo] +ProgressBarSections = dict[ProgressBarSection, ProgressBarSectionInfo] class ScanProgressBarSection(ProgressBarSection): @@ -138,8 +138,8 @@ def __init__(self, progress_bar_sections: ProgressBarSections) -> None: self._progress_bar_sections = progress_bar_sections - self._section_lengths: Dict[ProgressBarSection, int] = {} - self._section_values: Dict[ProgressBarSection, int] = {} + self._section_lengths: dict[ProgressBarSection, int] = {} + self._section_values: dict[ProgressBarSection, int] = {} self._current_section_value = 0 self._current_section: ProgressBarSectionInfo = _get_initial_section(self._progress_bar_sections) @@ -195,7 +195,7 @@ def _increment_section_value(self, section: 'ProgressBarSection', value: int) -> ) def _rerender_progress_bar(self) -> None: - """Used to update label right after changing the progress bar section.""" + """Use to update label right after changing the progress bar section.""" self._progress_bar_update() def _increment_progress(self, section: 'ProgressBarSection') -> None: diff --git a/cycode/cli/utils/scan_batch.py b/cycode/cli/utils/scan_batch.py index 45e4d120..8bfd7ed0 100644 --- a/cycode/cli/utils/scan_batch.py +++ b/cycode/cli/utils/scan_batch.py @@ -1,6 +1,6 @@ import os from multiprocessing.pool import ThreadPool -from typing import TYPE_CHECKING, Callable, Dict, List, Tuple +from typing import TYPE_CHECKING, Callable from cycode.cli import consts from cycode.cli.models import Document @@ -45,8 +45,8 @@ def _get_max_batch_files_count(_: str) -> int: def split_documents_into_batches( scan_type: str, - documents: List[Document], -) -> List[List[Document]]: + documents: list[Document], +) -> list[list[Document]]: max_size = _get_max_batch_size(scan_type) max_files_count = _get_max_batch_files_count(scan_type) @@ -107,11 +107,11 @@ def _get_threads_count() -> int: def run_parallel_batched_scan( - scan_function: Callable[[List[Document]], Tuple[str, 'CliError', 'LocalScanResult']], + scan_function: Callable[[list[Document]], tuple[str, 'CliError', 'LocalScanResult']], scan_type: str, - documents: List[Document], + documents: list[Document], progress_bar: 'BaseProgressBar', -) -> Tuple[Dict[str, 'CliError'], List['LocalScanResult']]: +) -> tuple[dict[str, 'CliError'], list['LocalScanResult']]: # batching is disabled for SCA; requested by Mor batches = [documents] if scan_type == consts.SCA_SCAN_TYPE else split_documents_into_batches(scan_type, documents) @@ -124,8 +124,8 @@ def run_parallel_batched_scan( # the progress bar could be significant improved (be more dynamic) in the future threads_count = _get_threads_count() - local_scan_results: List['LocalScanResult'] = [] - cli_errors: Dict[str, 'CliError'] = {} + local_scan_results: list[LocalScanResult] = [] + cli_errors: dict[str, CliError] = {} logger.debug('Running parallel batched scan, %s', {'threads_count': threads_count, 'batches_count': len(batches)}) diff --git a/cycode/cli/utils/shell_executor.py b/cycode/cli/utils/shell_executor.py index 812fee1f..db0331da 100644 --- a/cycode/cli/utils/shell_executor.py +++ b/cycode/cli/utils/shell_executor.py @@ -1,5 +1,5 @@ import subprocess -from typing import List, Optional, Union +from typing import Optional, Union import click import typer @@ -13,7 +13,7 @@ def shell( - command: Union[str, List[str]], + command: Union[str, list[str]], timeout: int = _SUBPROCESS_DEFAULT_TIMEOUT_SEC, working_directory: Optional[str] = None, ) -> Optional[str]: diff --git a/cycode/cli/utils/task_timer.py b/cycode/cli/utils/task_timer.py index 29e65dc8..4b5e903e 100644 --- a/cycode/cli/utils/task_timer.py +++ b/cycode/cli/utils/task_timer.py @@ -1,19 +1,18 @@ from _thread import interrupt_main from threading import Event, Thread from types import TracebackType -from typing import Callable, Dict, List, Optional, Type +from typing import Callable, Optional class FunctionContext: - def __init__(self, function: Callable, args: Optional[List] = None, kwargs: Optional[Dict] = None) -> None: + def __init__(self, function: Callable, args: Optional[list] = None, kwargs: Optional[dict] = None) -> None: self.function = function self.args = args or [] self.kwargs = kwargs or {} class TimerThread(Thread): - """ - Custom thread class for executing timer in the background + """Custom thread class for executing timer in the background. Members: timeout - the amount of time to count until timeout in seconds @@ -43,8 +42,7 @@ def _call_quit_function(self) -> None: class TimeoutAfter: - """ - A task wrapper for controlling how much time a task should be run before timing out + """A task wrapper for controlling how much time a task should be run before timing out. Use Example: with TimeoutAfter(5, repeat_function=FunctionContext(x), repeat_interval=2): @@ -66,7 +64,7 @@ def __enter__(self) -> None: self.timer.start() def __exit__( - self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType] + self, exc_type: Optional[type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType] ) -> None: if self.timeout: self.timer.stop() diff --git a/cycode/cli/utils/version_checker.py b/cycode/cli/utils/version_checker.py index 035b3595..47da17c4 100644 --- a/cycode/cli/utils/version_checker.py +++ b/cycode/cli/utils/version_checker.py @@ -2,7 +2,7 @@ import re import time from pathlib import Path -from typing import List, Optional, Tuple +from typing import Optional from cycode.cli.console import console from cycode.cli.user_settings.configuration_manager import ConfigurationManager @@ -11,8 +11,8 @@ def _compare_versions( - current_parts: List[int], - latest_parts: List[int], + current_parts: list[int], + latest_parts: list[int], current_is_pre: bool, latest_is_pre: bool, latest_version: str, @@ -33,6 +33,7 @@ def _compare_versions( Returns: str | None: The latest version string if an update is recommended, None if no update is needed + """ # If current is stable and latest is pre-release, don't suggest update if not current_is_pre and latest_is_pre: @@ -82,6 +83,7 @@ def get_latest_version(self) -> Optional[str]: Returns: str | None: The latest version string if successful, None if the request fails or the version information is not available. + """ try: response = self.get(f'{self.PYPI_PACKAGE_NAME}/json', timeout=self.PYPI_REQUEST_TIMEOUT) @@ -91,7 +93,7 @@ def get_latest_version(self) -> Optional[str]: return None @staticmethod - def _parse_version(version: str) -> Tuple[List[int], bool]: + def _parse_version(version: str) -> tuple[list[int], bool]: """Parse version string into components and identify if it's a pre-release. Extracts numeric version components and determines if the version is a pre-release @@ -104,6 +106,7 @@ def _parse_version(version: str) -> Tuple[List[int], bool]: tuple: A tuple containing: - List[int]: List of numeric version components - bool: True if this is a pre-release version, False otherwise + """ version_parts = [int(x) for x in re.findall(r'\d+', version)] is_prerelease = 'dev' in version @@ -122,6 +125,7 @@ def _should_check_update(self, is_prerelease: bool) -> bool: Returns: bool: True if an update check should be performed, False otherwise + """ if not os.path.exists(self.cache_file): return True @@ -148,7 +152,7 @@ def _update_last_check(self) -> None: os.makedirs(os.path.dirname(self.cache_file), exist_ok=True) with open(self.cache_file, 'w', encoding='UTF-8') as f: f.write(str(time.time())) - except IOError: + except OSError: pass def check_for_update(self, current_version: str, use_cache: bool = True) -> Optional[str]: @@ -163,6 +167,7 @@ def check_for_update(self, current_version: str, use_cache: bool = True) -> Opti Returns: str | None: The latest version string if an update is recommended, None if no update is needed or if check should be skipped + """ current_parts, current_is_pre = self._parse_version(current_version) @@ -192,6 +197,7 @@ def check_and_notify_update(self, current_version: str, use_cache: bool = True) Args: current_version: Current version of the CLI use_cache: If True, use the cached timestamp to determine if an update check is needed + """ latest_version = self.check_for_update(current_version, use_cache) should_update = bool(latest_version) diff --git a/cycode/cli/utils/yaml_utils.py b/cycode/cli/utils/yaml_utils.py index 388f3498..c89e1a5c 100644 --- a/cycode/cli/utils/yaml_utils.py +++ b/cycode/cli/utils/yaml_utils.py @@ -1,10 +1,11 @@ import os -from typing import Any, Dict, Hashable, TextIO +from collections.abc import Hashable +from typing import Any, TextIO import yaml -def _deep_update(source: Dict[Hashable, Any], overrides: Dict[Hashable, Any]) -> Dict[Hashable, Any]: +def _deep_update(source: dict[Hashable, Any], overrides: dict[Hashable, Any]) -> dict[Hashable, Any]: for key, value in overrides.items(): if isinstance(value, dict) and value: source[key] = _deep_update(source.get(key, {}), value) @@ -14,7 +15,7 @@ def _deep_update(source: Dict[Hashable, Any], overrides: Dict[Hashable, Any]) -> return source -def _yaml_safe_load(file: TextIO) -> Dict[Hashable, Any]: +def _yaml_safe_load(file: TextIO) -> dict[Hashable, Any]: # loader.get_single_data could return None loaded_file = yaml.safe_load(file) if loaded_file is None: @@ -23,18 +24,18 @@ def _yaml_safe_load(file: TextIO) -> Dict[Hashable, Any]: return loaded_file -def read_yaml_file(filename: str) -> Dict[Hashable, Any]: +def read_yaml_file(filename: str) -> dict[Hashable, Any]: if not os.path.exists(filename): return {} - with open(filename, 'r', encoding='UTF-8') as file: + with open(filename, encoding='UTF-8') as file: return _yaml_safe_load(file) -def write_yaml_file(filename: str, content: Dict[Hashable, Any]) -> None: +def write_yaml_file(filename: str, content: dict[Hashable, Any]) -> None: with open(filename, 'w', encoding='UTF-8') as file: yaml.safe_dump(content, file) -def update_yaml_file(filename: str, content: Dict[Hashable, Any]) -> None: +def update_yaml_file(filename: str, content: dict[Hashable, Any]) -> None: write_yaml_file(filename, _deep_update(read_yaml_file(filename), content)) diff --git a/cycode/cyclient/cycode_client_base.py b/cycode/cyclient/cycode_client_base.py index 37e9d4f6..4b2e2698 100644 --- a/cycode/cyclient/cycode_client_base.py +++ b/cycode/cyclient/cycode_client_base.py @@ -1,7 +1,7 @@ import os import platform import ssl -from typing import TYPE_CHECKING, Callable, ClassVar, Dict, Optional +from typing import TYPE_CHECKING, Callable, ClassVar, Optional import requests from requests import Response, exceptions @@ -14,7 +14,7 @@ RequestError, RequestHttpError, RequestSslError, - RequestTimeout, + RequestTimeoutError, ) from cycode.cyclient import config from cycode.cyclient.headers import get_cli_user_agent, get_correlation_id @@ -50,7 +50,7 @@ def _get_request_function() -> Callable: _REQUEST_ERRORS_TO_RETRY = ( - RequestTimeout, + RequestTimeoutError, RequestConnectionError, exceptions.ChunkedEncodingError, exceptions.ContentDecodingError, @@ -91,7 +91,7 @@ def _should_retry_exception(exception: BaseException) -> bool: class CycodeClientBase: - MANDATORY_HEADERS: ClassVar[Dict[str, str]] = { + MANDATORY_HEADERS: ClassVar[dict[str, str]] = { 'User-Agent': get_cli_user_agent(), 'X-Correlation-Id': get_correlation_id(), } @@ -160,7 +160,7 @@ def _execute( except Exception as e: self._handle_exception(e) - def get_request_headers(self, additional_headers: Optional[dict] = None, **kwargs) -> Dict[str, str]: + def get_request_headers(self, additional_headers: Optional[dict] = None, **kwargs) -> dict[str, str]: if additional_headers is None: return self.MANDATORY_HEADERS.copy() return {**self.MANDATORY_HEADERS, **additional_headers} @@ -170,7 +170,7 @@ def build_full_url(self, url: str, endpoint: str) -> str: def _handle_exception(self, e: Exception) -> None: if isinstance(e, exceptions.Timeout): - raise RequestTimeout from e + raise RequestTimeoutError from e if isinstance(e, exceptions.HTTPError): raise self._get_http_exception(e) from e if isinstance(e, exceptions.SSLError): diff --git a/cycode/cyclient/cycode_dev_based_client.py b/cycode/cyclient/cycode_dev_based_client.py index 347797c3..d8fe1cab 100644 --- a/cycode/cyclient/cycode_dev_based_client.py +++ b/cycode/cyclient/cycode_dev_based_client.py @@ -1,4 +1,4 @@ -from typing import Dict, Optional +from typing import Optional from cycode.cyclient.config import dev_tenant_id from cycode.cyclient.cycode_client_base import CycodeClientBase @@ -12,7 +12,7 @@ class CycodeDevBasedClient(CycodeClientBase): def __init__(self, api_url: str) -> None: super().__init__(api_url) - def get_request_headers(self, additional_headers: Optional[dict] = None, **_) -> Dict[str, str]: + def get_request_headers(self, additional_headers: Optional[dict] = None, **_) -> dict[str, str]: headers = super().get_request_headers(additional_headers=additional_headers) headers['X-Tenant-Id'] = dev_tenant_id diff --git a/cycode/cyclient/headers.py b/cycode/cyclient/headers.py index 76716826..5d10f69b 100644 --- a/cycode/cyclient/headers.py +++ b/cycode/cyclient/headers.py @@ -35,6 +35,7 @@ def get_correlation_id(self) -> str: Used across all requests to correlate logs and metrics. It doesn't depend on client instances. Lifetime is the same as the process. + """ if self._id is None: # example: 16fd2706-8baf-433b-82eb-8c7fada847da diff --git a/cycode/cyclient/models.py b/cycode/cyclient/models.py index 2c0f53d7..70e3e551 100644 --- a/cycode/cyclient/models.py +++ b/cycode/cyclient/models.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Dict, List, Optional +from typing import Any, Optional from marshmallow import EXCLUDE, Schema, fields, post_load @@ -47,12 +47,12 @@ class Meta: detection_rule_id = fields.String() @post_load - def build_dto(self, data: Dict[str, Any], **_) -> Detection: + def build_dto(self, data: dict[str, Any], **_) -> Detection: return Detection(**data) class DetectionsPerFile(Schema): - def __init__(self, file_name: str, detections: List[Detection], commit_id: Optional[str] = None) -> None: + def __init__(self, file_name: str, detections: list[Detection], commit_id: Optional[str] = None) -> None: super().__init__() self.file_name = file_name self.detections = detections @@ -63,7 +63,7 @@ class ZippedFileScanResult(Schema): def __init__( self, did_detect: bool, - detections_per_file: List[DetectionsPerFile], + detections_per_file: list[DetectionsPerFile], report_url: Optional[str] = None, scan_id: Optional[str] = None, err: Optional[str] = None, @@ -81,7 +81,7 @@ def __init__( self, did_detect: bool, scan_id: Optional[str] = None, - detections: Optional[List[Detection]] = None, + detections: Optional[list[Detection]] = None, err: Optional[str] = None, ) -> None: super().__init__() @@ -101,7 +101,7 @@ class Meta: err = fields.String() @post_load - def build_dto(self, data: Dict[str, Any], **_) -> 'ScanResult': + def build_dto(self, data: dict[str, Any], **_) -> 'ScanResult': return ScanResult(**data) @@ -120,7 +120,7 @@ class Meta: err = fields.String() @post_load - def build_dto(self, data: Dict[str, Any], **_) -> 'ScanInitializationResponse': + def build_dto(self, data: dict[str, Any], **_) -> 'ScanInitializationResponse': return ScanInitializationResponse(**data) @@ -154,7 +154,7 @@ class ScanReportUrlResponseSchema(Schema): report_url = fields.String() @post_load - def build_dto(self, data: Dict[str, Any], **_) -> 'ScanReportUrlResponse': + def build_dto(self, data: dict[str, Any], **_) -> 'ScanReportUrlResponse': return ScanReportUrlResponse(**data) @@ -171,12 +171,12 @@ class Meta: err = fields.String() @post_load - def build_dto(self, data: Dict[str, Any], **_) -> 'ScanDetailsResponse': + def build_dto(self, data: dict[str, Any], **_) -> 'ScanDetailsResponse': return ScanDetailsResponse(**data) class K8SResource: - def __init__(self, name: str, resource_type: str, namespace: str, content: Dict) -> None: + def __init__(self, name: str, resource_type: str, namespace: str, content: dict) -> None: super().__init__() self.name = name self.type = resource_type @@ -201,7 +201,7 @@ def to_json(self) -> dict: # FIXME(MarshalX): rename to to_dict? class ResourcesCollection: - def __init__(self, resource_type: str, namespace: str, resources: List[K8SResource], total_count: int) -> None: + def __init__(self, resource_type: str, namespace: str, resources: list[K8SResource], total_count: int) -> None: super().__init__() self.type = resource_type self.namespace = namespace @@ -240,7 +240,7 @@ def __init__(self, name: str, kind: str) -> None: self.kind = kind def __str__(self) -> str: - return 'Name: {0}, Kind: {1}'.format(self.name, self.kind) + return f'Name: {self.name}, Kind: {self.kind}' class AuthenticationSession(Schema): @@ -256,7 +256,7 @@ class Meta: session_id = fields.String() @post_load - def build_dto(self, data: Dict[str, Any], **_) -> 'AuthenticationSession': + def build_dto(self, data: dict[str, Any], **_) -> 'AuthenticationSession': return AuthenticationSession(**data) @@ -277,7 +277,7 @@ class Meta: description = fields.String() @post_load - def build_dto(self, data: Dict[str, Any], **_) -> 'ApiToken': + def build_dto(self, data: dict[str, Any], **_) -> 'ApiToken': return ApiToken(**data) @@ -296,7 +296,7 @@ class Meta: api_token = fields.Nested(ApiTokenSchema, allow_none=True) @post_load - def build_dto(self, data: Dict[str, Any], **_) -> 'ApiTokenGenerationPollingResponse': + def build_dto(self, data: dict[str, Any], **_) -> 'ApiTokenGenerationPollingResponse': return ApiTokenGenerationPollingResponse(**data) @@ -307,7 +307,7 @@ class UserAgentOptionScheme(Schema): env_version = fields.String(required=True) # ex. 1.78.2 @post_load - def build_dto(self, data: Dict[str, Any], **_) -> 'UserAgentOption': + def build_dto(self, data: dict[str, Any], **_) -> 'UserAgentOption': return UserAgentOption(**data) @@ -349,7 +349,7 @@ class Meta: size = fields.Integer() @post_load - def build_dto(self, data: Dict[str, Any], **_) -> SbomReportStorageDetails: + def build_dto(self, data: dict[str, Any], **_) -> SbomReportStorageDetails: return SbomReportStorageDetails(**data) @@ -373,13 +373,13 @@ class Meta: storage_details = fields.Nested(SbomReportStorageDetailsSchema, allow_none=True) @post_load - def build_dto(self, data: Dict[str, Any], **_) -> ReportExecution: + def build_dto(self, data: dict[str, Any], **_) -> ReportExecution: return ReportExecution(**data) @dataclass class SbomReport: - report_executions: List[ReportExecution] + report_executions: list[ReportExecution] class RequestedSbomReportResultSchema(Schema): @@ -389,7 +389,7 @@ class Meta: report_executions = fields.List(fields.Nested(ReportExecutionSchema)) @post_load - def build_dto(self, data: Dict[str, Any], **_) -> SbomReport: + def build_dto(self, data: dict[str, Any], **_) -> SbomReport: return SbomReport(**data) @@ -405,13 +405,13 @@ class Meta: severity = fields.String() @post_load - def build_dto(self, data: Dict[str, Any], **_) -> ClassificationData: + def build_dto(self, data: dict[str, Any], **_) -> ClassificationData: return ClassificationData(**data) @dataclass class DetectionRule: - classification_data: List[ClassificationData] + classification_data: list[ClassificationData] detection_rule_id: str custom_remediation_guidelines: Optional[str] = None remediation_guidelines: Optional[str] = None @@ -433,14 +433,14 @@ class Meta: display_name = fields.String(allow_none=True) @post_load - def build_dto(self, data: Dict[str, Any], **_) -> DetectionRule: + def build_dto(self, data: dict[str, Any], **_) -> DetectionRule: return DetectionRule(**data) @dataclass class ScanResultsSyncFlow: id: str - detection_messages: List[Dict] + detection_messages: list[dict] class ScanResultsSyncFlowSchema(Schema): @@ -451,7 +451,7 @@ class Meta: detection_messages = fields.List(fields.Dict()) @post_load - def build_dto(self, data: Dict[str, Any], **_) -> ScanResultsSyncFlow: + def build_dto(self, data: dict[str, Any], **_) -> ScanResultsSyncFlow: return ScanResultsSyncFlow(**data) @@ -489,5 +489,5 @@ class Meta: ai_large_language_model = fields.Boolean() @post_load - def build_dto(self, data: Dict[str, Any], **_) -> 'SupportedModulesPreferences': + def build_dto(self, data: dict[str, Any], **_) -> 'SupportedModulesPreferences': return SupportedModulesPreferences(**data) diff --git a/cycode/cyclient/report_client.py b/cycode/cyclient/report_client.py index fa8e0c3f..e8107827 100644 --- a/cycode/cyclient/report_client.py +++ b/cycode/cyclient/report_client.py @@ -1,6 +1,6 @@ import dataclasses import json -from typing import List, Optional +from typing import Optional from requests import Response @@ -97,5 +97,5 @@ def parse_requested_sbom_report_response(response: Response) -> models.SbomRepor return models.RequestedSbomReportResultSchema().load(response.json()) @staticmethod - def parse_execution_status_response(response: Response) -> List[models.ReportExecutionSchema]: + def parse_execution_status_response(response: Response) -> list[models.ReportExecutionSchema]: return models.ReportExecutionSchema().load(response.json(), many=True) diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index 09908943..e0bf8131 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -1,6 +1,6 @@ import json from copy import deepcopy -from typing import TYPE_CHECKING, List, Set, Union +from typing import TYPE_CHECKING, Union from uuid import UUID from requests import Response @@ -135,7 +135,7 @@ def get_scan_details_path(self, scan_type: str, scan_id: str) -> str: return f'{self.get_scan_service_url_path(scan_type)}/{scan_id}' def get_scan_aggregation_report_url_path(self, aggregation_id: str, scan_type: str) -> str: - return f'{self.get_scan_service_url_path(scan_type)}' f'/reportUrlByAggregationId/{aggregation_id}' + return f'{self.get_scan_service_url_path(scan_type)}/reportUrlByAggregationId/{aggregation_id}' def get_scan_details(self, scan_type: str, scan_id: str) -> models.ScanDetailsResponse: path = self.get_scan_details_path(scan_type, scan_id) @@ -190,10 +190,10 @@ def _get_policy_type_by_scan_type(scan_type: str) -> str: return scan_type_to_policy_type[scan_type] @staticmethod - def parse_detection_rules_response(response: Response) -> List[models.DetectionRule]: + def parse_detection_rules_response(response: Response) -> list[models.DetectionRule]: return models.DetectionRuleSchema().load(response.json(), many=True) - def get_detection_rules(self, detection_rules_ids: Union[Set[str], List[str]]) -> List[models.DetectionRule]: + def get_detection_rules(self, detection_rules_ids: Union[set[str], list[str]]) -> list[models.DetectionRule]: response = self.scan_cycode_client.get( url_path=self.get_detection_rules_path(), params={'ids': detection_rules_ids}, @@ -208,7 +208,7 @@ def get_scan_detections_path(self) -> str: def get_scan_detections_list_path(self) -> str: return f'{self.get_scan_detections_path()}/detections' - def get_scan_raw_detections(self, scan_id: str) -> List[dict]: + def get_scan_raw_detections(self, scan_id: str) -> list[dict]: params = {'scan_id': scan_id} page_size = 200 diff --git a/cycode/logger.py b/cycode/logger.py index b63c796f..0ec6023f 100644 --- a/cycode/logger.py +++ b/cycode/logger.py @@ -1,6 +1,6 @@ import logging import sys -from typing import NamedTuple, Optional, Set, Union +from typing import NamedTuple, Optional, Union import click import typer @@ -42,7 +42,7 @@ class CreatedLogger(NamedTuple): control_level_in_runtime: bool -_CREATED_LOGGERS: Set[CreatedLogger] = set() +_CREATED_LOGGERS: set[CreatedLogger] = set() def get_logger_level() -> Optional[Union[int, str]]: diff --git a/poetry.lock b/poetry.lock index d0b6503d..65e6a971 100644 --- a/poetry.lock +++ b/poetry.lock @@ -50,14 +50,14 @@ chardet = ">=3.0.2" [[package]] name = "certifi" -version = "2024.8.30" +version = "2025.4.26" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" groups = ["main", "test"] files = [ - {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, - {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, + {file = "certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3"}, + {file = "certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6"}, ] [[package]] @@ -74,129 +74,116 @@ files = [ [[package]] name = "charset-normalizer" -version = "3.4.0" +version = "3.4.1" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false -python-versions = ">=3.7.0" +python-versions = ">=3.7" groups = ["main", "test"] files = [ - {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"}, - {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"}, - {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, + {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, + {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, ] [[package]] name = "click" -version = "8.1.7" +version = "8.1.8" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, - {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, + {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, + {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, ] [package.dependencies] @@ -320,14 +307,14 @@ test = ["pytest (>=6)"] [[package]] name = "gitdb" -version = "4.0.11" +version = "4.0.12" description = "Git Object Database" optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "gitdb-4.0.11-py3-none-any.whl", hash = "sha256:81a3407ddd2ee8df444cbacea00e2d038e40150acfa3001696fe0dcf1d3adfa4"}, - {file = "gitdb-4.0.11.tar.gz", hash = "sha256:bf5421126136d6d0af55bc1e7c1af1c397a34f5b7bd79e776cd3e89785c2b04b"}, + {file = "gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf"}, + {file = "gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571"}, ] [package.dependencies] @@ -335,21 +322,21 @@ smmap = ">=3.0.1,<6" [[package]] name = "gitpython" -version = "3.1.43" +version = "3.1.44" description = "GitPython is a Python library used to interact with Git repositories" optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "GitPython-3.1.43-py3-none-any.whl", hash = "sha256:eec7ec56b92aad751f9912a73404bc02ba212a23adb2c7098ee668417051a1ff"}, - {file = "GitPython-3.1.43.tar.gz", hash = "sha256:35f314a9f878467f5453cc1fee295c3e18e52f1b99f10f6cf5b1682e968a9e7c"}, + {file = "GitPython-3.1.44-py3-none-any.whl", hash = "sha256:9e0e10cda9bed1ee64bc9a6de50e7e38a9c9943241cd7f585f6df3ed28011110"}, + {file = "gitpython-3.1.44.tar.gz", hash = "sha256:c87e30b26253bf5418b01b0660f818967f3c503193838337fe5e573331249269"}, ] [package.dependencies] gitdb = ">=4.0.1,<5" [package.extras] -doc = ["sphinx (==4.3.2)", "sphinx-autodoc-typehints", "sphinx-rtd-theme", "sphinxcontrib-applehelp (>=1.0.2,<=1.0.4)", "sphinxcontrib-devhelp (==1.0.2)", "sphinxcontrib-htmlhelp (>=2.0.0,<=2.0.1)", "sphinxcontrib-qthelp (==1.0.3)", "sphinxcontrib-serializinghtml (==1.1.5)"] +doc = ["sphinx (>=7.1.2,<7.2)", "sphinx-autodoc-typehints", "sphinx_rtd_theme"] test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock ; python_version < \"3.8\"", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions ; python_version < \"3.11\""] [[package]] @@ -369,15 +356,15 @@ all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2 [[package]] name = "importlib-metadata" -version = "8.5.0" +version = "8.7.0" description = "Read metadata from Python packages" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["executable"] markers = "python_version < \"3.10\"" files = [ - {file = "importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b"}, - {file = "importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7"}, + {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, + {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, ] [package.dependencies] @@ -389,19 +376,19 @@ cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] perf = ["ipython"] -test = ["flufl.flake8", "importlib-resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +test = ["flufl.flake8", "importlib_resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] type = ["pytest-mypy"] [[package]] name = "iniconfig" -version = "2.0.0" +version = "2.1.0" description = "brain-dead simple config-ini parsing" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" groups = ["test"] files = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] [[package]] @@ -496,14 +483,14 @@ test = ["pytest (<5.4)", "pytest-cov"] [[package]] name = "packaging" -version = "24.2" +version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" groups = ["main", "executable", "test"] files = [ - {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, - {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, ] [[package]] @@ -548,26 +535,26 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pyfakefs" -version = "5.7.2" +version = "5.7.4" description = "pyfakefs implements a fake file system that mocks the Python file system modules." optional = false python-versions = ">=3.7" groups = ["test"] files = [ - {file = "pyfakefs-5.7.2-py3-none-any.whl", hash = "sha256:e1527b0e8e4b33be52f0b024ca1deb269c73eecd68457c6b0bf608d6dab12ebd"}, - {file = "pyfakefs-5.7.2.tar.gz", hash = "sha256:40da84175c5af8d9c4f3b31800b8edc4af1e74a212671dd658b21cc881c60000"}, + {file = "pyfakefs-5.7.4-py3-none-any.whl", hash = "sha256:3e763d700b91c54ade6388be2cfa4e521abc00e34f7defb84ee511c73031f45f"}, + {file = "pyfakefs-5.7.4.tar.gz", hash = "sha256:4971e65cc80a93a1e6f1e3a4654909c0c493186539084dc9301da3d68c8878fe"}, ] [[package]] name = "pygments" -version = "2.18.0" +version = "2.19.1" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, - {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, + {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, + {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, ] [package.extras] @@ -610,32 +597,32 @@ hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"] [[package]] name = "pyinstaller-hooks-contrib" -version = "2024.10" +version = "2025.3" description = "Community maintained hooks for PyInstaller" optional = false python-versions = ">=3.8" groups = ["executable"] markers = "python_version < \"3.13\"" files = [ - {file = "pyinstaller_hooks_contrib-2024.10-py3-none-any.whl", hash = "sha256:ad47db0e153683b4151e10d231cb91f2d93c85079e78d76d9e0f57ac6c8a5e10"}, - {file = "pyinstaller_hooks_contrib-2024.10.tar.gz", hash = "sha256:8a46655e5c5b0186b5e527399118a9b342f10513eb1425c483fa4f6d02e8800c"}, + {file = "pyinstaller_hooks_contrib-2025.3-py3-none-any.whl", hash = "sha256:70cba46b1a6b82ae9104f074c25926e31f3dde50ff217434d1d660355b949683"}, + {file = "pyinstaller_hooks_contrib-2025.3.tar.gz", hash = "sha256:af129da5cd6219669fbda360e295cc822abac55b7647d03fec63a8fcf0a608cf"}, ] [package.dependencies] -importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} +importlib_metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} packaging = ">=22.0" setuptools = ">=42.0.0" [[package]] name = "pyjwt" -version = "2.9.0" +version = "2.10.1" description = "JSON Web Token implementation in Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "PyJWT-2.9.0-py3-none-any.whl", hash = "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850"}, - {file = "pyjwt-2.9.0.tar.gz", hash = "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c"}, + {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, + {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, ] [package.extras] @@ -841,42 +828,42 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "ruff" -version = "0.6.9" +version = "0.11.7" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "ruff-0.6.9-py3-none-linux_armv6l.whl", hash = "sha256:064df58d84ccc0ac0fcd63bc3090b251d90e2a372558c0f057c3f75ed73e1ccd"}, - {file = "ruff-0.6.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:140d4b5c9f5fc7a7b074908a78ab8d384dd7f6510402267bc76c37195c02a7ec"}, - {file = "ruff-0.6.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53fd8ca5e82bdee8da7f506d7b03a261f24cd43d090ea9db9a1dc59d9313914c"}, - {file = "ruff-0.6.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645d7d8761f915e48a00d4ecc3686969761df69fb561dd914a773c1a8266e14e"}, - {file = "ruff-0.6.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eae02b700763e3847595b9d2891488989cac00214da7f845f4bcf2989007d577"}, - {file = "ruff-0.6.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d5ccc9e58112441de8ad4b29dcb7a86dc25c5f770e3c06a9d57e0e5eba48829"}, - {file = "ruff-0.6.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:417b81aa1c9b60b2f8edc463c58363075412866ae4e2b9ab0f690dc1e87ac1b5"}, - {file = "ruff-0.6.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c866b631f5fbce896a74a6e4383407ba7507b815ccc52bcedabb6810fdb3ef7"}, - {file = "ruff-0.6.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b118afbb3202f5911486ad52da86d1d52305b59e7ef2031cea3425142b97d6f"}, - {file = "ruff-0.6.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a67267654edc23c97335586774790cde402fb6bbdb3c2314f1fc087dee320bfa"}, - {file = "ruff-0.6.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3ef0cc774b00fec123f635ce5c547dac263f6ee9fb9cc83437c5904183b55ceb"}, - {file = "ruff-0.6.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:12edd2af0c60fa61ff31cefb90aef4288ac4d372b4962c2864aeea3a1a2460c0"}, - {file = "ruff-0.6.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:55bb01caeaf3a60b2b2bba07308a02fca6ab56233302406ed5245180a05c5625"}, - {file = "ruff-0.6.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:925d26471fa24b0ce5a6cdfab1bb526fb4159952385f386bdcc643813d472039"}, - {file = "ruff-0.6.9-py3-none-win32.whl", hash = "sha256:eb61ec9bdb2506cffd492e05ac40e5bc6284873aceb605503d8494180d6fc84d"}, - {file = "ruff-0.6.9-py3-none-win_amd64.whl", hash = "sha256:785d31851c1ae91f45b3d8fe23b8ae4b5170089021fbb42402d811135f0b7117"}, - {file = "ruff-0.6.9-py3-none-win_arm64.whl", hash = "sha256:a9641e31476d601f83cd602608739a0840e348bda93fec9f1ee816f8b6798b93"}, - {file = "ruff-0.6.9.tar.gz", hash = "sha256:b076ef717a8e5bc819514ee1d602bbdca5b4420ae13a9cf61a0c0a4f53a2baa2"}, + {file = "ruff-0.11.7-py3-none-linux_armv6l.whl", hash = "sha256:d29e909d9a8d02f928d72ab7837b5cbc450a5bdf578ab9ebee3263d0a525091c"}, + {file = "ruff-0.11.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dd1fb86b168ae349fb01dd497d83537b2c5541fe0626e70c786427dd8363aaee"}, + {file = "ruff-0.11.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d3d7d2e140a6fbbc09033bce65bd7ea29d6a0adeb90b8430262fbacd58c38ada"}, + {file = "ruff-0.11.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4809df77de390a1c2077d9b7945d82f44b95d19ceccf0c287c56e4dc9b91ca64"}, + {file = "ruff-0.11.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3a0c2e169e6b545f8e2dba185eabbd9db4f08880032e75aa0e285a6d3f48201"}, + {file = "ruff-0.11.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49b888200a320dd96a68e86736cf531d6afba03e4f6cf098401406a257fcf3d6"}, + {file = "ruff-0.11.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2b19cdb9cf7dae00d5ee2e7c013540cdc3b31c4f281f1dacb5a799d610e90db4"}, + {file = "ruff-0.11.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64e0ee994c9e326b43539d133a36a455dbaab477bc84fe7bfbd528abe2f05c1e"}, + {file = "ruff-0.11.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bad82052311479a5865f52c76ecee5d468a58ba44fb23ee15079f17dd4c8fd63"}, + {file = "ruff-0.11.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7940665e74e7b65d427b82bffc1e46710ec7f30d58b4b2d5016e3f0321436502"}, + {file = "ruff-0.11.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:169027e31c52c0e36c44ae9a9c7db35e505fee0b39f8d9fca7274a6305295a92"}, + {file = "ruff-0.11.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:305b93f9798aee582e91e34437810439acb28b5fc1fee6b8205c78c806845a94"}, + {file = "ruff-0.11.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a681db041ef55550c371f9cd52a3cf17a0da4c75d6bd691092dfc38170ebc4b6"}, + {file = "ruff-0.11.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:07f1496ad00a4a139f4de220b0c97da6d4c85e0e4aa9b2624167b7d4d44fd6b6"}, + {file = "ruff-0.11.7-py3-none-win32.whl", hash = "sha256:f25dfb853ad217e6e5f1924ae8a5b3f6709051a13e9dad18690de6c8ff299e26"}, + {file = "ruff-0.11.7-py3-none-win_amd64.whl", hash = "sha256:0a931d85959ceb77e92aea4bbedfded0a31534ce191252721128f77e5ae1f98a"}, + {file = "ruff-0.11.7-py3-none-win_arm64.whl", hash = "sha256:778c1e5d6f9e91034142dfd06110534ca13220bfaad5c3735f6cb844654f6177"}, + {file = "ruff-0.11.7.tar.gz", hash = "sha256:655089ad3224070736dc32844fde783454f8558e71f501cb207485fe4eee23d4"}, ] [[package]] name = "sentry-sdk" -version = "2.19.2" +version = "2.27.0" description = "Python client for Sentry (https://sentry.io)" optional = false python-versions = ">=3.6" groups = ["main"] files = [ - {file = "sentry_sdk-2.19.2-py2.py3-none-any.whl", hash = "sha256:ebdc08228b4d131128e568d696c210d846e5b9d70aa0327dec6b1272d9d40b84"}, - {file = "sentry_sdk-2.19.2.tar.gz", hash = "sha256:467df6e126ba242d39952375dd816fbee0f217d119bf454a8ce74cf1e7909e8d"}, + {file = "sentry_sdk-2.27.0-py2.py3-none-any.whl", hash = "sha256:c58935bfff8af6a0856d37e8adebdbc7b3281c2b632ec823ef03cd108d216ff0"}, + {file = "sentry_sdk-2.27.0.tar.gz", hash = "sha256:90f4f883f9eff294aff59af3d58c2d1b64e3927b28d5ada2b9b41f5aeda47daf"}, ] [package.dependencies] @@ -920,29 +907,31 @@ sanic = ["sanic (>=0.8)"] sqlalchemy = ["sqlalchemy (>=1.2)"] starlette = ["starlette (>=0.19.1)"] starlite = ["starlite (>=1.48)"] +statsig = ["statsig (>=0.55.3)"] tornado = ["tornado (>=6)"] +unleash = ["UnleashClient (>=6.0.1)"] [[package]] name = "setuptools" -version = "75.3.0" +version = "80.0.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["executable"] markers = "python_version < \"3.13\"" files = [ - {file = "setuptools-75.3.0-py3-none-any.whl", hash = "sha256:f2504966861356aa38616760c0f66568e535562374995367b4e69c7143cf6bcd"}, - {file = "setuptools-75.3.0.tar.gz", hash = "sha256:fba5dd4d766e97be1b1681d98712680ae8f2f26d7881245f2ce9e40714f1a686"}, + {file = "setuptools-80.0.0-py3-none-any.whl", hash = "sha256:a38f898dcd6e5380f4da4381a87ec90bd0a7eec23d204a5552e80ee3cab6bd27"}, + {file = "setuptools-80.0.0.tar.gz", hash = "sha256:c40a5b3729d58dd749c0f08f1a07d134fb8a0a3d7f87dc33e7c5e1f762138650"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.5.2) ; sys_platform != \"cygwin\""] -core = ["importlib-metadata (>=6) ; python_version < \"3.10\"", "importlib-resources (>=5.10.2) ; python_version < \"3.9\"", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test (>=5.5)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib-metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.12.*)", "pytest-mypy"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] [[package]] name = "shellingham" @@ -970,14 +959,14 @@ files = [ [[package]] name = "smmap" -version = "5.0.1" +version = "5.0.2" description = "A pure Python implementation of a sliding window memory map manager" optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "smmap-5.0.1-py3-none-any.whl", hash = "sha256:e6d8668fa5f93e706934a62d7b4db19c8d9eb8cf2adbb75ef1b675aa332b69da"}, - {file = "smmap-5.0.1.tar.gz", hash = "sha256:dceeb6c0028fdb6734471eb07c0cd2aae706ccaecab45965ee83f11c8d3b1f62"}, + {file = "smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e"}, + {file = "smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5"}, ] [[package]] @@ -1041,14 +1030,14 @@ files = [ [[package]] name = "typer" -version = "0.15.2" +version = "0.15.3" description = "Typer, build great CLIs. Easy to code. Based on Python type hints." optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc"}, - {file = "typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5"}, + {file = "typer-0.15.3-py3-none-any.whl", hash = "sha256:c86a65ad77ca531f03de08d1b9cb67cd09ad02ddddf4b34745b5008f43b239bd"}, + {file = "typer-0.15.3.tar.gz", hash = "sha256:818873625d0569653438316567861899f7e9972f2e6e0c16dab608345ced713c"}, ] [package.dependencies] @@ -1071,26 +1060,26 @@ files = [ [[package]] name = "types-pyyaml" -version = "6.0.12.20240917" +version = "6.0.12.20250402" description = "Typing stubs for PyYAML" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["test"] files = [ - {file = "types-PyYAML-6.0.12.20240917.tar.gz", hash = "sha256:d1405a86f9576682234ef83bcb4e6fff7c9305c8b1fbad5e0bcd4f7dbdc9c587"}, - {file = "types_PyYAML-6.0.12.20240917-py3-none-any.whl", hash = "sha256:392b267f1c0fe6022952462bf5d6523f31e37f6cea49b14cee7ad634b6301570"}, + {file = "types_pyyaml-6.0.12.20250402-py3-none-any.whl", hash = "sha256:652348fa9e7a203d4b0d21066dfb00760d3cbd5a15ebb7cf8d33c88a49546681"}, + {file = "types_pyyaml-6.0.12.20250402.tar.gz", hash = "sha256:d7c13c3e6d335b6af4b0122a01ff1d270aba84ab96d1a1a1063ecba3e13ec075"}, ] [[package]] name = "typing-extensions" -version = "4.12.2" +version = "4.13.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, - {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, + {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, + {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, ] [[package]] @@ -1112,15 +1101,15 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "zipp" -version = "3.20.2" +version = "3.21.0" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["executable"] markers = "python_version < \"3.10\"" files = [ - {file = "zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350"}, - {file = "zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29"}, + {file = "zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931"}, + {file = "zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4"}, ] [package.extras] @@ -1134,4 +1123,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9,<3.14" -content-hash = "590be7f6a392d52a8d298596ef95e6ee664a8a3515530b01d727fe268e15fb0d" +content-hash = "14f258101aa534aadfc871aa5082ad773aa99873587c21c0598567435bfa5d9a" diff --git a/process_executable_file.py b/process_executable_file.py index ad4d702a..367bb18d 100755 --- a/process_executable_file.py +++ b/process_executable_file.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 -""" -Used in the GitHub Actions workflow (build_executable.yml) to process the executable file. +"""Used in the GitHub Actions workflow (build_executable.yml) to process the executable file. + This script calculates hash and renames executable file depending on the OS, arch, and build mode. It also creates a file with the hash of the executable file. It uses SHA256 algorithm to calculate the hash. @@ -15,7 +15,7 @@ import shutil from pathlib import Path from string import Template -from typing import List, Tuple, Union +from typing import Union _ARCHIVE_FORMAT = 'zip' _HASH_FILE_EXT = '.sha256' @@ -27,7 +27,7 @@ _WINDOWS = 'windows' _WINDOWS_EXECUTABLE_SUFFIX = '.exe' -DirHashes = List[Tuple[str, str]] +DirHashes = list[tuple[str, str]] def get_hash_of_file(file_path: Union[str, Path]) -> str: @@ -35,7 +35,7 @@ def get_hash_of_file(file_path: Union[str, Path]) -> str: return hashlib.sha256(f.read()).hexdigest() -def get_hashes_of_many_files(root: str, file_paths: List[str]) -> DirHashes: +def get_hashes_of_many_files(root: str, file_paths: list[str]) -> DirHashes: hashes = [] for file_path in file_paths: diff --git a/pyproject.toml b/pyproject.toml index cde794b7..755d8207 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ sentry-sdk = ">=2.8.0,<3.0" pyjwt = ">=2.8.0,<3.0" rich = ">=13.9.4, <14" patch-ng = "1.18.1" -typer = "^0.15.2" +typer = "^0.15.3" tenacity = ">=9.0.0,<9.1.0" [tool.poetry.group.test.dependencies] @@ -56,7 +56,7 @@ pyinstaller = {version=">=5.13.2,<5.14.0", python=">=3.8,<3.13"} dunamai = ">=1.18.0,<1.22.0" [tool.poetry.group.dev.dependencies] -ruff = "0.6.9" +ruff = "0.11.7" [tool.pytest.ini_options] log_cli = true @@ -73,7 +73,7 @@ style = "pep440" [tool.ruff] line-length = 120 -target-version = "py38" +target-version = "py39" [tool.ruff.lint] extend-select = [ @@ -81,6 +81,7 @@ extend-select = [ "W", # pycodestyle warnings "F", # Pyflakes "I", # isort + "N", # pep8 naming "C90", # flake8-comprehensions "B", # flake8-bugbear "Q", # flake8-quotes @@ -100,19 +101,26 @@ extend-select = [ "RSE", "RUF", "SIM", + "T10", "T20", - "TCH", "TID", "YTT", + "LOG", "G", + "UP", + "DTZ", + "PYI", + "PT", + "SLOT", + "TC", ] ignore = [ "ANN002", # Missing type annotation for `*args` "ANN003", # Missing type annotation for `**kwargs` - "ANN101", # Missing type annotation for `self` in method - "ANN102", # Missing type annotation for `cls` in classmethod "ANN401", # Dynamically typed expressions (typing.Any) "ISC001", # Conflicts with ruff format + "S105", # False positives + "PT012", # `pytest.raises()` block should contain a single simple statement ] [tool.ruff.lint.flake8-quotes] diff --git a/tests/cli/commands/version/test_version_checker.py b/tests/cli/commands/version/test_version_checker.py index 926a21e8..14d6150e 100644 --- a/tests/cli/commands/version/test_version_checker.py +++ b/tests/cli/commands/version/test_version_checker.py @@ -71,7 +71,7 @@ def test_should_check_update_prerelease_daily(self, version_checker_cached: 'Ver assert version_checker_cached._should_check_update(is_prerelease=True) is True @pytest.mark.parametrize( - 'current_version, latest_version, expected_result', + ('current_version', 'latest_version', 'expected_result'), [ # Stable version comparisons ('1.2.3', '1.2.4', '1.2.4'), # Higher patch version diff --git a/tests/cli/exceptions/test_handle_scan_errors.py b/tests/cli/exceptions/test_handle_scan_errors.py index abd297db..ce72e9de 100644 --- a/tests/cli/exceptions/test_handle_scan_errors.py +++ b/tests/cli/exceptions/test_handle_scan_errors.py @@ -17,7 +17,7 @@ from _pytest.monkeypatch import MonkeyPatch -@pytest.fixture() +@pytest.fixture def ctx() -> typer.Context: ctx = typer.Context(click.Command('path'), obj={'verbose': False, 'output': OutputTypeOption.TEXT}) ctx.obj['console_printer'] = ConsolePrinter(ctx) @@ -25,7 +25,7 @@ def ctx() -> typer.Context: @pytest.mark.parametrize( - 'exception, expected_soft_fail', + ('exception', 'expected_soft_fail'), [ (custom_exceptions.RequestHttpError(400, 'msg', Response()), True), (custom_exceptions.ScanAsyncError('msg'), True), diff --git a/tests/cli/files_collector/test_walk_ignore.py b/tests/cli/files_collector/test_walk_ignore.py index b771cdf9..12b9d428 100644 --- a/tests/cli/files_collector/test_walk_ignore.py +++ b/tests/cli/files_collector/test_walk_ignore.py @@ -1,6 +1,6 @@ import os from os.path import normpath -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING from cycode.cli.files_collector.walk_ignore import ( _collect_top_level_ignore_files, @@ -95,7 +95,7 @@ def test_collect_top_level_ignore_files(fs: 'FakeFilesystem') -> None: fs.create_file('/home/user/project/.gitignore', contents='*.pyc\n*.log') -def _collect_walk_ignore_files(path: str) -> List[str]: +def _collect_walk_ignore_files(path: str) -> list[str]: files = [] for root, _, filenames in walk_ignore(path): for filename in filenames: diff --git a/tests/cyclient/test_auth_client.py b/tests/cyclient/test_auth_client.py index 67147a6e..24d9b096 100644 --- a/tests/cyclient/test_auth_client.py +++ b/tests/cyclient/test_auth_client.py @@ -4,7 +4,7 @@ from requests import Timeout from cycode.cli.apps.auth.auth_manager import AuthManager -from cycode.cli.exceptions.custom_exceptions import CycodeError, RequestTimeout +from cycode.cli.exceptions.custom_exceptions import CycodeError, RequestTimeoutError from cycode.cyclient.auth_client import AuthClient from cycode.cyclient.models import ( ApiTokenGenerationPollingResponse, @@ -73,7 +73,7 @@ def test_start_session_timeout(client: AuthClient, start_url: str, code_challeng responses.add(responses.POST, start_url, body=timeout_error) - with pytest.raises(RequestTimeout): + with pytest.raises(RequestTimeoutError): client.start_session(code_challenge) diff --git a/tests/cyclient/test_scan_client.py b/tests/cyclient/test_scan_client.py index d81116fb..d6928118 100644 --- a/tests/cyclient/test_scan_client.py +++ b/tests/cyclient/test_scan_client.py @@ -1,5 +1,4 @@ import os -from typing import List, Tuple from uuid import uuid4 import pytest @@ -12,7 +11,7 @@ CycodeError, HttpUnauthorizedError, RequestConnectionError, - RequestTimeout, + RequestTimeoutError, ) from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip from cycode.cli.models import Document @@ -28,7 +27,7 @@ ) -def zip_scan_resources(scan_type: ScanTypeOption, scan_client: ScanClient) -> Tuple[str, InMemoryZip]: +def zip_scan_resources(scan_type: ScanTypeOption, scan_client: ScanClient) -> tuple[str, InMemoryZip]: url = get_zipped_file_scan_async_url(scan_type, scan_client) zip_file = get_test_zip_file(scan_type) @@ -37,11 +36,11 @@ def zip_scan_resources(scan_type: ScanTypeOption, scan_client: ScanClient) -> Tu def get_test_zip_file(scan_type: ScanTypeOption) -> InMemoryZip: # TODO(MarshalX): refactor scan_disk_files in code_scanner.py to reuse method here instead of this - test_documents: List[Document] = [] + test_documents: list[Document] = [] for root, _, files in os.walk(ZIP_CONTENT_PATH): for name in files: path = os.path.join(root, name) - with open(path, 'r', encoding='UTF-8') as f: + with open(path, encoding='UTF-8') as f: test_documents.append(Document(path, f.read(), is_git_diff_format=False)) from cycode.cli.files_collector.zip_documents import zip_documents @@ -132,7 +131,7 @@ def test_zipped_file_scan_async_timeout_error( responses.add(api_token_response) # mock token based client responses.add(method=responses.POST, url=url, body=timeout_error) - with pytest.raises(RequestTimeout): + with pytest.raises(RequestTimeoutError): scan_client.zipped_file_scan_async(zip_file=zip_file, scan_type=scan_type, scan_parameters={}) diff --git a/tests/test_performance_get_all_files.py b/tests/test_performance_get_all_files.py index 60155261..b0e8653d 100644 --- a/tests/test_performance_get_all_files.py +++ b/tests/test_performance_get_all_files.py @@ -3,17 +3,17 @@ import os import timeit from pathlib import Path -from typing import Dict, List, Tuple, Union +from typing import Union logger = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) -def filter_files(paths: List[Union[Path, str]]) -> List[str]: +def filter_files(paths: list[Union[Path, str]]) -> list[str]: return [str(path) for path in paths if os.path.isfile(path)] -def get_all_files_glob(path: Union[Path, str]) -> List[str]: +def get_all_files_glob(path: Union[Path, str]) -> list[str]: # DOESN'T RETURN HIDDEN FILES. CAN'T BE USED # and doesn't show the best performance if not str(path).endswith(os.sep): @@ -22,7 +22,7 @@ def get_all_files_glob(path: Union[Path, str]) -> List[str]: return filter_files(glob.glob(f'{path}**', recursive=True)) -def get_all_files_walk(path: str) -> List[str]: +def get_all_files_walk(path: str) -> list[str]: files = [] for root, _, filenames in os.walk(path): @@ -32,7 +32,7 @@ def get_all_files_walk(path: str) -> List[str]: return files -def get_all_files_listdir(path: str) -> List[str]: +def get_all_files_listdir(path: str) -> list[str]: files = [] def _(sub_path: str) -> None: @@ -50,12 +50,12 @@ def _(sub_path: str) -> None: return files -def get_all_files_rglob(path: str) -> List[str]: +def get_all_files_rglob(path: str) -> list[str]: return filter_files(list(Path(path).rglob(r'*'))) def test_get_all_files_performance(test_files_path: str) -> None: - results: Dict[str, Tuple[int, float]] = {} + results: dict[str, tuple[int, float]] = {} for func in { get_all_files_rglob, get_all_files_listdir, diff --git a/tests/user_settings/test_configuration_manager.py b/tests/user_settings/test_configuration_manager.py index 50251340..5aa7f6a8 100644 --- a/tests/user_settings/test_configuration_manager.py +++ b/tests/user_settings/test_configuration_manager.py @@ -1,6 +1,5 @@ from typing import TYPE_CHECKING, Optional - -from mock import Mock +from unittest.mock import Mock from cycode.cli.consts import DEFAULT_CYCODE_API_URL from cycode.cli.user_settings.configuration_manager import ConfigurationManager diff --git a/tests/utils/test_ignore_utils.py b/tests/utils/test_ignore_utils.py index 563c11a9..6988e1aa 100644 --- a/tests/utils/test_ignore_utils.py +++ b/tests/utils/test_ignore_utils.py @@ -87,9 +87,9 @@ def test_translate(self) -> None: for pattern, regex in TRANSLATE_TESTS: if re.escape(b'/') == b'/': regex = regex.replace(b'\\/', b'/') - assert ( - translate(pattern) == regex - ), f'orig pattern: {pattern!r}, regex: {translate(pattern)!r}, expected: {regex!r}' + assert translate(pattern) == regex, ( + f'orig pattern: {pattern!r}, regex: {translate(pattern)!r}, expected: {regex!r}' + ) def test_read_file(self) -> None: f = BytesIO( From f29a3825dbb0900e9aef2389c4da2757ef5890d1 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 30 Apr 2025 16:47:50 +0200 Subject: [PATCH 165/257] Make changes in CLI v3.0.0 after feedback (part 2) (#303) --- README.md | 32 ++------- cycode/__main__.py | 4 ++ cycode/cli/__main__.py | 3 - cycode/cli/app.py | 47 +++++------- cycode/cli/apps/ai_remediation/__init__.py | 6 +- .../ai_remediation/ai_remediation_command.py | 2 +- cycode/cli/apps/auth/__init__.py | 7 +- cycode/cli/apps/auth/auth_common.py | 5 +- cycode/cli/apps/configure/__init__.py | 7 +- cycode/cli/apps/report/__init__.py | 2 +- .../cli/apps/report/sbom/path/path_command.py | 2 +- .../repository_url/repository_url_command.py | 2 +- cycode/cli/apps/scan/__init__.py | 7 +- cycode/cli/apps/scan/code_scanner.py | 11 ++- cycode/cli/apps/scan/scan_command.py | 72 ++++++++++++------- cycode/cli/apps/status/get_cli_status.py | 2 +- cycode/cli/printers/console_printer.py | 43 ++++++----- cycode/cli/printers/printer_base.py | 7 +- cycode/cli/printers/rich_printer.py | 2 + .../cli/printers/tables/sca_table_printer.py | 11 ++- cycode/cli/printers/tables/table_printer.py | 5 +- .../cli/printers/tables/table_printer_base.py | 36 +++------- cycode/cli/printers/text_printer.py | 11 ++- cycode/cli/printers/utils/__init__.py | 8 +++ .../cli/printers/utils/code_snippet_syntax.py | 36 +++++----- cycode/cli/printers/utils/detection_data.py | 22 +++++- cycode/cli/utils/get_api_client.py | 15 ++-- cycode/cli/utils/version_checker.py | 10 +-- cycode/cyclient/scan_client.py | 6 -- .../test_check_latest_version_on_close.py | 2 +- tests/test_code_scanner.py | 11 ++- 31 files changed, 217 insertions(+), 219 deletions(-) create mode 100644 cycode/__main__.py delete mode 100644 cycode/cli/__main__.py diff --git a/README.md b/README.md index fbe5c6a6..7966361b 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,9 @@ This guide walks you through both installation and usage. 1. [Options](#options) 1. [Severity Threshold](#severity-option) 2. [Monitor](#monitor-option) - 3. [Report](#report-option) - 4. [Package Vulnerabilities](#package-vulnerabilities-option) - 5. [License Compliance](#license-compliance-option) - 6. [Lock Restore](#lock-restore-option) + 3. [Package Vulnerabilities](#package-vulnerabilities-option) + 4. [License Compliance](#license-compliance-option) + 5. [Lock Restore](#lock-restore-option) 2. [Repository Scan](#repository-scan) 1. [Branch Option](#branch-option) 3. [Path Scan](#path-scan) @@ -282,7 +281,7 @@ The following are the options and commands available with the Cycode CLI applica | [configure](#using-the-configure-command) | Initial command to configure your CLI client authentication. | | [ignore](#ignoring-scan-results) | Ignores a specific value, path or rule ID. | | [scan](#running-a-scan) | Scan the content for Secrets/IaC/SCA/SAST violations. You`ll need to specify which scan type to perform: commit-history/path/repository/etc. | -| [report](#report-command) | Generate report. You`ll need to specify which report type to perform. | +| [report](#report-command) | Generate report. You`ll need to specify which report type to perform as SBOM. | | status | Show the CLI status and exit. | # Scan Command @@ -301,7 +300,6 @@ The Cycode CLI application offers several types of scans so that you can choose | `--severity-threshold [INFO\|LOW\|MEDIUM\|HIGH\|CRITICAL]` | Show only violations at the specified level or higher. | | `--sca-scan` | Specify the SCA scan you wish to execute (`package-vulnerabilities`/`license-compliance`). The default is both. | | `--monitor` | When specified, the scan results will be recorded in the knowledge graph. Please note that when working in `monitor` mode, the knowledge graph will not be updated as a result of SCM events (Push, Repo creation). (Supported for SCA scan type only). | -| `--report` | When specified, a violations report will be generated. A URL link to the report will be printed as an output to the command execution. | | `--no-restore` | When specified, Cycode will not run restore command. Will scan direct dependencies ONLY! | | `--gradle-all-sub-projects` | When specified, Cycode will run gradle restore command for all sub projects. Should run from root project directory ONLY! | | `--help` | Show options for given command. | @@ -339,28 +337,6 @@ When using this option, the scan results from this scan will appear in the knowl > [!WARNING] > You must be an `owner` or an `admin` in Cycode to view the knowledge graph page. -#### Report Option - -> [!NOTE] -> This option is not available to IaC scans. - -To push scan results tied to the [SCA policies](https://docs.cycode.com/docs/sca-policies) found in the Repository scan to Cycode, add the argument `--report` to the scan command. - -`cycode scan -t sca --report repository ~/home/git/codebase` - -In the same way, you can push scan results of Secrets and SAST scans to Cycode by adding the `--report` option to the scan command. - -When using this option, the scan results from this scan will appear in the On-Demand Scans section of Cycode. To get to this page, click the link that appears after the printed results: - -> [!WARNING] -> You must be an `owner` or an `admin` in Cycode to view this page. - -![cli-report](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/sca_report_url.png) - -The report page will look something like below: - -![](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/scan_details.png) - #### Package Vulnerabilities Option > [!NOTE] diff --git a/cycode/__main__.py b/cycode/__main__.py new file mode 100644 index 00000000..7ad8ef7e --- /dev/null +++ b/cycode/__main__.py @@ -0,0 +1,4 @@ +from cycode.cli.consts import PROGRAM_NAME +from cycode.cli.main import app + +app(prog_name=PROGRAM_NAME) diff --git a/cycode/cli/__main__.py b/cycode/cli/__main__.py deleted file mode 100644 index dad7ac12..00000000 --- a/cycode/cli/__main__.py +++ /dev/null @@ -1,3 +0,0 @@ -from cycode.cli.main import app - -app() diff --git a/cycode/cli/app.py b/cycode/cli/app.py index b07b3221..6fd4f70d 100644 --- a/cycode/cli/app.py +++ b/cycode/cli/app.py @@ -1,14 +1,14 @@ import logging -from pathlib import Path from typing import Annotated, Optional import typer from typer import rich_utils +from typer._completion_classes import completion_init from typer.completion import install_callback, show_callback from cycode import __version__ from cycode.cli.apps import ai_remediation, auth, configure, ignore, report, scan, status -from cycode.cli.cli_types import ExportTypeOption, OutputTypeOption +from cycode.cli.cli_types import OutputTypeOption from cycode.cli.consts import CLI_CONTEXT_SETTINGS from cycode.cli.printers import ConsolePrinter from cycode.cli.user_settings.configuration_manager import ConfigurationManager @@ -24,14 +24,10 @@ # By default, it uses blue color which is too dark for some terminals rich_utils.RICH_HELP = "Try [cyan]'{command_path} {help_option}'[/] for help." +completion_init() # DO NOT TOUCH; this is required for the completion to work properly _cycode_cli_docs = 'https://github.com/cycodehq/cycode-cli/blob/main/README.md' -_cycode_cli_epilog = f"""[bold]Documentation[/] - - - -For more details and advanced usage, visit: [link={_cycode_cli_docs}]{_cycode_cli_docs}[/link] -""" +_cycode_cli_epilog = f'[bold]Documentation:[/] [link={_cycode_cli_docs}]{_cycode_cli_docs}[/link]' app = typer.Typer( pretty_exceptions_show_locals=False, @@ -64,13 +60,14 @@ def check_latest_version_on_close(ctx: typer.Context) -> None: def export_if_needed_on_close(ctx: typer.Context) -> None: + scan_finalized = ctx.obj.get('scan_finalized') printer = ctx.obj.get('console_printer') - if printer.is_recording: + if scan_finalized and printer.is_recording: printer.export() +_AUTH_RICH_HELP_PANEL = 'Authentication options' _COMPLETION_RICH_HELP_PANEL = 'Completion options' -_EXPORT_RICH_HELP_PANEL = 'Export options' @app.callback() @@ -90,25 +87,18 @@ def app_callback( Optional[str], typer.Option(hidden=True, help='Characteristic JSON object that lets servers identify the application.'), ] = None, - export_type: Annotated[ - ExportTypeOption, + client_secret: Annotated[ + Optional[str], typer.Option( - '--export-type', - case_sensitive=False, - help='Specify the export type. ' - 'HTML and SVG will export terminal output and rely on --output option. ' - 'JSON always exports JSON.', - rich_help_panel=_EXPORT_RICH_HELP_PANEL, + help='Specify a Cycode client secret for this specific scan execution.', + rich_help_panel=_AUTH_RICH_HELP_PANEL, ), - ] = ExportTypeOption.JSON, - export_file: Annotated[ - Optional[Path], + ] = None, + client_id: Annotated[ + Optional[str], typer.Option( - '--export-file', - help='Export file. Path to the file where the export will be saved. ', - dir_okay=False, - writable=True, - rich_help_panel=_EXPORT_RICH_HELP_PANEL, + help='Specify a Cycode client ID for this specific scan execution.', + rich_help_panel=_AUTH_RICH_HELP_PANEL, ), ] = None, _: Annotated[ @@ -150,10 +140,11 @@ def app_callback( if output == OutputTypeOption.JSON: no_progress_meter = True + ctx.obj['client_id'] = client_id + ctx.obj['client_secret'] = client_secret + ctx.obj['progress_bar'] = get_progress_bar(hidden=no_progress_meter, sections=SCAN_PROGRESS_BAR_SECTIONS) - ctx.obj['export_type'] = export_type - ctx.obj['export_file'] = export_file ctx.obj['console_printer'] = ConsolePrinter(ctx) ctx.call_on_close(lambda: export_if_needed_on_close(ctx)) diff --git a/cycode/cli/apps/ai_remediation/__init__.py b/cycode/cli/apps/ai_remediation/__init__.py index cd471a08..00d0c7c5 100644 --- a/cycode/cli/apps/ai_remediation/__init__.py +++ b/cycode/cli/apps/ai_remediation/__init__.py @@ -4,9 +4,9 @@ app = typer.Typer() -_ai_remediation_epilog = """ -Note: AI remediation suggestions are generated automatically and should be reviewed before applying. -""" +_ai_remediation_epilog = ( + 'Note: AI remediation suggestions are generated automatically and should be reviewed before applying.' +) app.command( name='ai-remediation', diff --git a/cycode/cli/apps/ai_remediation/ai_remediation_command.py b/cycode/cli/apps/ai_remediation/ai_remediation_command.py index ea5ef826..ab2eca5e 100644 --- a/cycode/cli/apps/ai_remediation/ai_remediation_command.py +++ b/cycode/cli/apps/ai_remediation/ai_remediation_command.py @@ -24,7 +24,7 @@ def ai_remediation_command( * `cycode ai-remediation `: View remediation guidance * `cycode ai-remediation --fix`: Apply suggested fixes """ - client = get_scan_cycode_client() + client = get_scan_cycode_client(ctx) try: remediation_markdown = client.get_ai_remediation(detection_id) diff --git a/cycode/cli/apps/auth/__init__.py b/cycode/cli/apps/auth/__init__.py index beecae38..f487e1bf 100644 --- a/cycode/cli/apps/auth/__init__.py +++ b/cycode/cli/apps/auth/__init__.py @@ -3,12 +3,7 @@ from cycode.cli.apps.auth.auth_command import auth_command _auth_command_docs = 'https://github.com/cycodehq/cycode-cli/blob/main/README.md#using-the-auth-command' -_auth_command_epilog = f"""[bold]Documentation[/] - - - -For more details and advanced usage, visit: [link={_auth_command_docs}]{_auth_command_docs}[/link] -""" +_auth_command_epilog = f'[bold]Documentation:[/] [link={_auth_command_docs}]{_auth_command_docs}[/link]' app = typer.Typer(no_args_is_help=False) app.command(name='auth', epilog=_auth_command_epilog, short_help='Authenticate your machine with Cycode.')(auth_command) diff --git a/cycode/cli/apps/auth/auth_common.py b/cycode/cli/apps/auth/auth_common.py index 52b7b6fa..96fec4cf 100644 --- a/cycode/cli/apps/auth/auth_common.py +++ b/cycode/cli/apps/auth/auth_common.py @@ -13,7 +13,10 @@ def get_authorization_info(ctx: 'Context') -> Optional[AuthInfo]: printer = ctx.obj.get('console_printer') - client_id, client_secret = CredentialsManager().get_credentials() + client_id, client_secret = ctx.obj.get('client_id'), ctx.obj.get('client_secret') + if not client_id or not client_secret: + client_id, client_secret = CredentialsManager().get_credentials() + if not client_id or not client_secret: return None diff --git a/cycode/cli/apps/configure/__init__.py b/cycode/cli/apps/configure/__init__.py index ce73c450..4944a3e3 100644 --- a/cycode/cli/apps/configure/__init__.py +++ b/cycode/cli/apps/configure/__init__.py @@ -3,12 +3,7 @@ from cycode.cli.apps.configure.configure_command import configure_command _configure_command_docs = 'https://github.com/cycodehq/cycode-cli/blob/main/README.md#using-the-configure-command' -_configure_command_epilog = f"""[bold]Documentation[/] - - - -For more details and advanced usage, visit: [link={_configure_command_docs}]{_configure_command_docs}[/link] -""" +_configure_command_epilog = f'[bold]Documentation:[/] [link={_configure_command_docs}]{_configure_command_docs}[/link]' app = typer.Typer(no_args_is_help=True) diff --git a/cycode/cli/apps/report/__init__.py b/cycode/cli/apps/report/__init__.py index 40cc696a..751157a4 100644 --- a/cycode/cli/apps/report/__init__.py +++ b/cycode/cli/apps/report/__init__.py @@ -4,5 +4,5 @@ from cycode.cli.apps.report.report_command import report_command app = typer.Typer(name='report', no_args_is_help=True) -app.callback(short_help='Generate report. You`ll need to specify which report type to perform.')(report_command) +app.callback(short_help='Generate report. You`ll need to specify which report type to perform as SBOM.')(report_command) app.add_typer(sbom.app) diff --git a/cycode/cli/apps/report/sbom/path/path_command.py b/cycode/cli/apps/report/sbom/path/path_command.py index 20e82848..9741aa73 100644 --- a/cycode/cli/apps/report/sbom/path/path_command.py +++ b/cycode/cli/apps/report/sbom/path/path_command.py @@ -24,7 +24,7 @@ def path_command( ) -> None: add_breadcrumb('path') - client = get_report_cycode_client() + client = get_report_cycode_client(ctx) report_parameters = ctx.obj['report_parameters'] output_format = report_parameters.output_format output_file = ctx.obj['output_file'] diff --git a/cycode/cli/apps/report/sbom/repository_url/repository_url_command.py b/cycode/cli/apps/report/sbom/repository_url/repository_url_command.py index 28be0114..9e2f4885 100644 --- a/cycode/cli/apps/report/sbom/repository_url/repository_url_command.py +++ b/cycode/cli/apps/report/sbom/repository_url/repository_url_command.py @@ -20,7 +20,7 @@ def repository_url_command( progress_bar.start() progress_bar.set_section_length(SbomReportProgressBarSection.PREPARE_LOCAL_FILES) - client = get_report_cycode_client() + client = get_report_cycode_client(ctx) report_parameters = ctx.obj['report_parameters'] output_file = ctx.obj['output_file'] output_format = report_parameters.output_format diff --git a/cycode/cli/apps/scan/__init__.py b/cycode/cli/apps/scan/__init__.py index ada2d105..b4d8ab79 100644 --- a/cycode/cli/apps/scan/__init__.py +++ b/cycode/cli/apps/scan/__init__.py @@ -10,12 +10,7 @@ app = typer.Typer(name='scan', no_args_is_help=True) _scan_command_docs = 'https://github.com/cycodehq/cycode-cli/blob/main/README.md#scan-command' -_scan_command_epilog = f"""[bold]Documentation[/] - - - -For more details and advanced usage, visit: [link={_scan_command_docs}]{_scan_command_docs}[/link] -""" +_scan_command_epilog = f'[bold]Documentation:[/] [link={_scan_command_docs}]{_scan_command_docs}[/link]' app.callback( short_help='Scan the content for Secrets, IaC, SCA, and SAST violations.', diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index 0209d9da..04aae2e3 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -323,7 +323,7 @@ def scan_documents( scan_batch_thread_func, scan_type, documents_to_scan, progress_bar=progress_bar ) - aggregation_report_url = _try_get_aggregation_report_url_if_needed(scan_parameters, ctx.obj['client'], scan_type) + aggregation_report_url = _try_get_aggregation_report_url(scan_parameters, ctx.obj['client'], scan_type) _set_aggregation_report_url(ctx, aggregation_report_url) progress_bar.set_section_length(ScanProgressBarSection.GENERATE_REPORT, 1) @@ -571,6 +571,7 @@ def print_results( ctx: typer.Context, local_scan_results: list[LocalScanResult], errors: Optional[dict[str, 'CliError']] = None ) -> None: printer = ctx.obj.get('console_printer') + printer.update_ctx(ctx) printer.print_scan_results(local_scan_results, errors) @@ -640,7 +641,6 @@ def parse_pre_receive_input() -> str: def _get_default_scan_parameters(ctx: typer.Context) -> dict: return { 'monitor': ctx.obj.get('monitor'), - 'report': ctx.obj.get('report'), 'package_vulnerabilities': ctx.obj.get('package-vulnerabilities'), 'license_compliance': ctx.obj.get('license-compliance'), 'command_type': ctx.info_name.replace('-', '_'), # save backward compatibility @@ -956,7 +956,7 @@ def _get_scan_result( did_detect=True, detections_per_file=_map_detections_per_file_and_commit_id(scan_type, scan_raw_detections), scan_id=scan_id, - report_url=_try_get_aggregation_report_url_if_needed(scan_parameters, cycode_client, scan_type), + report_url=_try_get_aggregation_report_url(scan_parameters, cycode_client, scan_type), ) @@ -972,12 +972,9 @@ def _set_aggregation_report_url(ctx: typer.Context, aggregation_report_url: Opti ctx.obj['aggregation_report_url'] = aggregation_report_url -def _try_get_aggregation_report_url_if_needed( +def _try_get_aggregation_report_url( scan_parameters: dict, cycode_client: 'ScanClient', scan_type: str ) -> Optional[str]: - if not scan_parameters.get('report', False): - return None - aggregation_id = scan_parameters.get('aggregation_id') if aggregation_id is None: return None diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 38e4a610..2d323706 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -1,9 +1,10 @@ +from pathlib import Path from typing import Annotated, Optional import click import typer -from cycode.cli.cli_types import ScanTypeOption, ScaScanTypeOption, SeverityOption +from cycode.cli.cli_types import ExportTypeOption, ScanTypeOption, ScaScanTypeOption, SeverityOption from cycode.cli.consts import ( ISSUE_DETECTED_STATUS_CODE, NO_ISSUES_STATUS_CODE, @@ -12,8 +13,9 @@ from cycode.cli.utils.get_api_client import get_scan_cycode_client from cycode.cli.utils.sentry import add_breadcrumb -_AUTH_RICH_HELP_PANEL = 'Authentication options' +_EXPORT_RICH_HELP_PANEL = 'Export options' _SCA_RICH_HELP_PANEL = 'SCA options' +_SECRET_RICH_HELP_PANEL = 'Secret options' def scan_command( @@ -27,21 +29,6 @@ def scan_command( case_sensitive=False, ), ] = ScanTypeOption.SECRET, - client_secret: Annotated[ - Optional[str], - typer.Option( - help='Specify a Cycode client secret for this specific scan execution.', - rich_help_panel=_AUTH_RICH_HELP_PANEL, - ), - ] = None, - client_id: Annotated[ - Optional[str], - typer.Option( - help='Specify a Cycode client ID for this specific scan execution.', - rich_help_panel=_AUTH_RICH_HELP_PANEL, - ), - ] = None, - show_secret: Annotated[bool, typer.Option('--show-secret', help='Show Secrets in plain text.')] = False, soft_fail: Annotated[ bool, typer.Option('--soft-fail', help='Run the scan without failing; always return a non-error status code.') ] = False, @@ -58,13 +45,8 @@ def scan_command( '--sync', help='Run scan synchronously (INTERNAL FOR IDEs).', show_default='asynchronously', hidden=True ), ] = False, - report: Annotated[ - bool, - typer.Option( - '--report', - help='When specified, generates a violations report. ' - 'A link to the report will be displayed in the console output.', - ), + show_secret: Annotated[ + bool, typer.Option('--show-secret', help='Show Secrets in plain text.', rich_help_panel=_SECRET_RICH_HELP_PANEL) ] = False, sca_scan: Annotated[ list[ScaScanTypeOption], @@ -98,6 +80,27 @@ def scan_command( rich_help_panel=_SCA_RICH_HELP_PANEL, ), ] = False, + export_type: Annotated[ + ExportTypeOption, + typer.Option( + '--export-type', + case_sensitive=False, + help='Specify the export type. ' + 'HTML and SVG will export terminal output and rely on --output option. ' + 'JSON always exports JSON.', + rich_help_panel=_EXPORT_RICH_HELP_PANEL, + ), + ] = None, + export_file: Annotated[ + Optional[Path], + typer.Option( + '--export-file', + help='Export file. Path to the file where the export will be saved.', + dir_okay=False, + writable=True, + rich_help_panel=_EXPORT_RICH_HELP_PANEL, + ), + ] = None, ) -> None: """:mag: [bold cyan]Scan code for vulnerabilities (Secrets, IaC, SCA, SAST).[/] @@ -115,14 +118,28 @@ def scan_command( """ add_breadcrumb('scan') + if export_file and export_type is None: + raise typer.BadParameter( + 'Export type must be specified when --export-file is provided.', + param_hint='--export-type', + ) + if export_type and export_file is None: + raise typer.BadParameter( + 'Export file must be specified when --export-type is provided.', + param_hint='--export-file', + ) + ctx.obj['show_secret'] = show_secret ctx.obj['soft_fail'] = soft_fail - ctx.obj['client'] = get_scan_cycode_client(client_id, client_secret, not ctx.obj['show_secret']) + ctx.obj['client'] = get_scan_cycode_client(ctx) ctx.obj['scan_type'] = scan_type ctx.obj['sync'] = sync ctx.obj['severity_threshold'] = severity_threshold ctx.obj['monitor'] = monitor - ctx.obj['report'] = report + + if export_type and export_file: + console_printer = ctx.obj['console_printer'] + console_printer.enable_recording(export_type, export_file) _ = no_restore, gradle_all_sub_projects # they are actually used; via ctx.params @@ -136,7 +153,8 @@ def _sca_scan_to_context(ctx: typer.Context, sca_scan_user_selected: list[str]) @click.pass_context def scan_command_result_callback(ctx: click.Context, *_, **__) -> None: - add_breadcrumb('scan_finalize') + add_breadcrumb('scan_finalized') + ctx.obj['scan_finalized'] = True progress_bar = ctx.obj.get('progress_bar') if progress_bar: diff --git a/cycode/cli/apps/status/get_cli_status.py b/cycode/cli/apps/status/get_cli_status.py index 0a272c57..0cf6e8fd 100644 --- a/cycode/cli/apps/status/get_cli_status.py +++ b/cycode/cli/apps/status/get_cli_status.py @@ -22,7 +22,7 @@ def get_cli_status(ctx: 'Context') -> CliStatus: supported_modules_status = CliSupportedModulesStatus() if is_authenticated: try: - client = get_scan_cycode_client() + client = get_scan_cycode_client(ctx) supported_modules_preferences = client.get_supported_modules_preferences() supported_modules_status.secret_scanning = supported_modules_preferences.secret_scanning diff --git a/cycode/cli/printers/console_printer.py b/cycode/cli/printers/console_printer.py index f581c894..17c402ff 100644 --- a/cycode/cli/printers/console_printer.py +++ b/cycode/cli/printers/console_printer.py @@ -16,6 +16,8 @@ from cycode.cli.printers.text_printer import TextPrinter if TYPE_CHECKING: + from pathlib import Path + from cycode.cli.models import LocalScanResult from cycode.cli.printers.tables.table_printer_base import PrinterBase @@ -43,17 +45,9 @@ def __init__( self.console_err = console_err_override or console_err self.output_type = output_type_override or self.ctx.obj.get('output') - self.console_record = None - - self.export_type = self.ctx.obj.get('export_type') - self.export_file = self.ctx.obj.get('export_file') - if console_override is None and self.export_type and self.export_file: - self.console_record = ConsolePrinter( - ctx, - console_override=Console(record=True, file=io.StringIO()), - console_err_override=Console(stderr=True, record=True, file=io.StringIO()), - output_type_override='json' if self.export_type == 'json' else self.output_type, - ) + self.export_type: Optional[str] = None + self.export_file: Optional[Path] = None + self.console_record: Optional[ConsolePrinter] = None @property def scan_type(self) -> str: @@ -76,6 +70,21 @@ def printer(self) -> 'PrinterBase': return printer_class(self.ctx, self.console, self.console_err) + def update_ctx(self, ctx: 'typer.Context') -> None: + self.ctx = ctx + + def enable_recording(self, export_type: str, export_file: 'Path') -> None: + if self.console_record is None: + self.export_file = export_file + self.export_type = export_type + + self.console_record = ConsolePrinter( + self.ctx, + console_override=Console(record=True, file=io.StringIO()), + console_err_override=Console(stderr=True, record=True, file=io.StringIO()), + output_type_override='json' if self.export_type == 'json' else self.output_type, + ) + def print_scan_results( self, local_scan_results: list['LocalScanResult'], @@ -106,16 +115,18 @@ def export(self) -> None: if self.console_record is None: raise CycodeError('Console recording was not enabled. Cannot export.') - if not self.export_file.suffix: + export_file = self.export_file + if not export_file.suffix: # resolve file extension based on the export type if not provided in the file name - self.export_file = self.export_file.with_suffix(f'.{self.export_type.lower()}') + export_file = export_file.with_suffix(f'.{self.export_type.lower()}') + export_file = str(export_file) if self.export_type is ExportTypeOption.HTML: - self.console_record.console.save_html(self.export_file) + self.console_record.console.save_html(export_file) elif self.export_type is ExportTypeOption.SVG: - self.console_record.console.save_svg(self.export_file, title=consts.APP_NAME) + self.console_record.console.save_svg(export_file, title=consts.APP_NAME) elif self.export_type is ExportTypeOption.JSON: - with open(self.export_file, 'w', encoding='UTF-8') as f: + with open(export_file, 'w', encoding='UTF-8') as f: self.console_record.console.file.seek(0) f.write(self.console_record.console.file.read()) else: diff --git a/cycode/cli/printers/printer_base.py b/cycode/cli/printers/printer_base.py index 527cc31b..69596e2a 100644 --- a/cycode/cli/printers/printer_base.py +++ b/cycode/cli/printers/printer_base.py @@ -20,10 +20,10 @@ class PrinterBase(ABC): NO_DETECTIONS_MESSAGE = ( - '[green]Good job! No issues were found!!! :clapping_hands::clapping_hands::clapping_hands:[/]' + '[b green]Good job! No issues were found!!! :clapping_hands::clapping_hands::clapping_hands:[/]' ) FAILED_SCAN_MESSAGE = ( - '[red]Unfortunately, Cycode was unable to complete the full scan. ' + '[b red]Unfortunately, Cycode was unable to complete the full scan. ' 'Please note that not all results may be available:[/]' ) @@ -99,6 +99,7 @@ def print_scan_results_summary(self, local_scan_results: list['LocalScanResult'] detections_count += 1 severity_counts[SeverityOption(detection.severity)] += 1 + self.console.line() self.console.print(f'[bold]Cycode found {detections_count} violations[/]', end=': ') # Example of string: CRITICAL - 6 | HIGH - 0 | MEDIUM - 14 | LOW - 0 | INFO - 0 @@ -110,3 +111,5 @@ def print_scan_results_summary(self, local_scan_results: list['LocalScanResult'] self.console.print( SeverityOption.get_member_emoji(severity), severity, '-', severity_counts[severity], end=end ) + + self.console.line() diff --git a/cycode/cli/printers/rich_printer.py b/cycode/cli/printers/rich_printer.py index b2ed1a2e..755278d6 100644 --- a/cycode/cli/printers/rich_printer.py +++ b/cycode/cli/printers/rich_printer.py @@ -111,6 +111,8 @@ def _print_violation_card( detection, document, obfuscate=not self.show_secret, + lines_to_display_before=3, + lines_to_display_after=3, ), title=':computer: Code Snippet', ) diff --git a/cycode/cli/printers/tables/sca_table_printer.py b/cycode/cli/printers/tables/sca_table_printer.py index 0bf59a20..1bf358c8 100644 --- a/cycode/cli/printers/tables/sca_table_printer.py +++ b/cycode/cli/printers/tables/sca_table_printer.py @@ -7,6 +7,7 @@ from cycode.cli.printers.tables.table import Table from cycode.cli.printers.tables.table_models import ColumnInfoBuilder from cycode.cli.printers.tables.table_printer_base import TablePrinterBase +from cycode.cli.printers.utils import is_git_diff_based_scan from cycode.cli.printers.utils.detection_ordering.sca_ordering import sort_and_group_detections from cycode.cli.utils.string_utils import shortcut_dependency_paths @@ -31,23 +32,19 @@ class ScaTablePrinter(TablePrinterBase): def _print_results(self, local_scan_results: list['LocalScanResult']) -> None: - aggregation_report_url = self.ctx.obj.get('aggregation_report_url') detections_per_policy_id = self._extract_detections_per_policy_id(local_scan_results) for policy_id, detections in detections_per_policy_id.items(): table = self._get_table(policy_id) resulting_detections, group_separator_indexes = sort_and_group_detections(detections) for detection in resulting_detections: - self._enrich_table_with_values(policy_id, table, detection) + self._enrich_table_with_values(table, detection) table.set_group_separator_indexes(group_separator_indexes) self._print_summary_issues(len(detections), self._get_title(policy_id)) self._print_table(table) - self.print_scan_results_summary(local_scan_results) - self._print_report_urls(local_scan_results, aggregation_report_url) - @staticmethod def _get_title(policy_id: str) -> str: if policy_id == PACKAGE_VULNERABILITY_POLICY_ID: @@ -66,7 +63,7 @@ def _get_table(self, policy_id: str) -> Table: elif policy_id == LICENSE_COMPLIANCE_POLICY_ID: table.add_column(LICENSE_COLUMN) - if self._is_git_repository(): + if is_git_diff_based_scan(self.scan_type, self.command_scan_type): table.add_column(REPOSITORY_COLUMN) table.add_column(SEVERITY_COLUMN) @@ -80,7 +77,7 @@ def _get_table(self, policy_id: str) -> Table: return table @staticmethod - def _enrich_table_with_values(policy_id: str, table: Table, detection: Detection) -> None: + def _enrich_table_with_values(table: Table, detection: Detection) -> None: detection_details = detection.detection_details if detection.severity: diff --git a/cycode/cli/printers/tables/table_printer.py b/cycode/cli/printers/tables/table_printer.py index fe9f8dd5..6fc85a1b 100644 --- a/cycode/cli/printers/tables/table_printer.py +++ b/cycode/cli/printers/tables/table_printer.py @@ -6,6 +6,7 @@ from cycode.cli.printers.tables.table import Table from cycode.cli.printers.tables.table_models import ColumnInfoBuilder from cycode.cli.printers.tables.table_printer_base import TablePrinterBase +from cycode.cli.printers.utils import is_git_diff_based_scan from cycode.cli.printers.utils.detection_ordering.common_ordering import sort_and_group_detections_from_scan_result from cycode.cli.utils.string_utils import get_position_in_line, obfuscate_text @@ -37,8 +38,6 @@ def _print_results(self, local_scan_results: list['LocalScanResult']) -> None: table.set_group_separator_indexes(group_separator_indexes) self._print_table(table) - self.print_scan_results_summary(local_scan_results) - self._print_report_urls(local_scan_results, self.ctx.obj.get('aggregation_report_url')) def _get_table(self) -> Table: table = Table() @@ -49,7 +48,7 @@ def _get_table(self) -> Table: table.add_column(LINE_NUMBER_COLUMN) table.add_column(COLUMN_NUMBER_COLUMN) - if self._is_git_repository(): + if is_git_diff_based_scan(self.scan_type, self.command_scan_type): table.add_column(COMMIT_SHA_COLUMN) if self.scan_type == SECRET_SCAN_TYPE: diff --git a/cycode/cli/printers/tables/table_printer_base.py b/cycode/cli/printers/tables/table_printer_base.py index d7a2b502..8cb4cbda 100644 --- a/cycode/cli/printers/tables/table_printer_base.py +++ b/cycode/cli/printers/tables/table_printer_base.py @@ -11,11 +11,15 @@ class TablePrinterBase(PrinterBase, abc.ABC): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.text_printer = TextPrinter(self.ctx, self.console, self.console_err) + def print_result(self, result: CliResult) -> None: - TextPrinter(self.ctx, self.console, self.console_err).print_result(result) + self.text_printer.print_result(result) def print_error(self, error: CliError) -> None: - TextPrinter(self.ctx, self.console, self.console_err).print_error(error) + self.text_printer.print_error(error) def print_scan_results( self, local_scan_results: list['LocalScanResult'], errors: Optional[dict[str, 'CliError']] = None @@ -26,16 +30,8 @@ def print_scan_results( self._print_results(local_scan_results) - if not errors: - return - - self.console.print(self.FAILED_SCAN_MESSAGE) - for scan_id, error in errors.items(): - self.console.print(f'- {scan_id}: ', end='') - self.print_error(error) - - def _is_git_repository(self) -> bool: - return self.ctx.info_name in {'commit_history', 'pre_commit', 'pre_receive'} and 'remote_url' in self.ctx.obj + self.print_scan_results_summary(local_scan_results) + self.text_printer.print_report_urls_and_errors(local_scan_results, errors) @abc.abstractmethod def _print_results(self, local_scan_results: list['LocalScanResult']) -> None: @@ -44,19 +40,3 @@ def _print_results(self, local_scan_results: list['LocalScanResult']) -> None: def _print_table(self, table: 'Table') -> None: if table.get_rows(): self.console.print(table.get_table()) - - def _print_report_urls( - self, - local_scan_results: list['LocalScanResult'], - aggregation_report_url: Optional[str] = None, - ) -> None: - report_urls = [scan_result.report_url for scan_result in local_scan_results if scan_result.report_url] - if not report_urls and not aggregation_report_url: - return - if aggregation_report_url: - self.console.print(f'Report URL: {aggregation_report_url}') - return - - self.console.print('Report URLs:') - for report_url in report_urls: - self.console.print(f'- {report_url}') diff --git a/cycode/cli/printers/text_printer.py b/cycode/cli/printers/text_printer.py index 564456ae..05a360fd 100644 --- a/cycode/cli/printers/text_printer.py +++ b/cycode/cli/printers/text_printer.py @@ -98,10 +98,15 @@ def print_report_urls_and_errors( def print_report_urls(self, report_urls: list[str], aggregation_report_url: Optional[str] = None) -> None: if not report_urls and not aggregation_report_url: return - if aggregation_report_url: - self.console.print(f'Report URL: {aggregation_report_url}') + + # Prioritize aggregation report URL; if report urls is only one, use it instead + single_url = report_urls[0] if len(report_urls) == 1 else None + single_url = aggregation_report_url or single_url + if single_url: + self.console.print(f'[b]Report URL:[/] {single_url}') return - self.console.print('Report URLs:') + # If there are multiple report URLs, print them all + self.console.print('[b]Report URLs:[/]') for report_url in report_urls: self.console.print(f'- {report_url}') diff --git a/cycode/cli/printers/utils/__init__.py b/cycode/cli/printers/utils/__init__.py index e69de29b..e1676c35 100644 --- a/cycode/cli/printers/utils/__init__.py +++ b/cycode/cli/printers/utils/__init__.py @@ -0,0 +1,8 @@ +from cycode.cli import consts + + +def is_git_diff_based_scan(scan_type: str, command_scan_type: str) -> bool: + return ( + command_scan_type in consts.COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES + and scan_type in consts.COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES + ) diff --git a/cycode/cli/printers/utils/code_snippet_syntax.py b/cycode/cli/printers/utils/code_snippet_syntax.py index aae33872..12501544 100644 --- a/cycode/cli/printers/utils/code_snippet_syntax.py +++ b/cycode/cli/printers/utils/code_snippet_syntax.py @@ -1,10 +1,10 @@ -import math from typing import TYPE_CHECKING from rich.syntax import Syntax from cycode.cli import consts from cycode.cli.console import _SYNTAX_HIGHLIGHT_THEME +from cycode.cli.printers.utils import is_git_diff_based_scan from cycode.cli.utils.string_utils import get_position_in_line, obfuscate_text if TYPE_CHECKING: @@ -12,8 +12,8 @@ from cycode.cyclient.models import Detection -def _get_code_segment_start_line(detection_line: int, lines_to_display: int) -> int: - start_line = detection_line - math.ceil(lines_to_display / 2) +def _get_code_segment_start_line(detection_line: int, lines_to_display_before: int) -> int: + start_line = detection_line - lines_to_display_before return 0 if start_line < 0 else start_line @@ -26,17 +26,24 @@ def get_detection_line(scan_type: str, detection: 'Detection') -> int: def _get_code_snippet_syntax_from_file( - scan_type: str, detection: 'Detection', document: 'Document', lines_to_display: int, obfuscate: bool + scan_type: str, + detection: 'Detection', + document: 'Document', + lines_to_display_before: int, + lines_to_display_after: int, + obfuscate: bool, ) -> Syntax: detection_details = detection.detection_details detection_line = get_detection_line(scan_type, detection) - start_line_index = _get_code_segment_start_line(detection_line, lines_to_display) + start_line_index = _get_code_segment_start_line(detection_line, lines_to_display_before) detection_position = get_position_in_line(document.content, detection_details.get('start_position', -1)) violation_length = detection_details.get('length', -1) code_lines_to_render = [] document_content_lines = document.content.splitlines() - for line_index in range(lines_to_display): + total_lines_to_display = lines_to_display_before + 1 + lines_to_display_after + + for line_index in range(total_lines_to_display): current_line_index = start_line_index + line_index if current_line_index >= len(document_content_lines): break @@ -56,6 +63,7 @@ def _get_code_snippet_syntax_from_file( code=code_to_render, lexer=Syntax.guess_lexer(document.path, code=code_to_render), line_numbers=True, + word_wrap=True, dedent=True, tab_size=2, start_line=start_line_index + 1, @@ -91,23 +99,19 @@ def _get_code_snippet_syntax_from_git_diff( ) -def _is_git_diff_based_scan(scan_type: str, command_scan_type: str) -> bool: - return ( - command_scan_type in consts.COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES - and scan_type in consts.COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES - ) - - def get_code_snippet_syntax( scan_type: str, command_scan_type: str, detection: 'Detection', document: 'Document', - lines_to_display: int = 3, + lines_to_display_before: int = 1, + lines_to_display_after: int = 1, obfuscate: bool = True, ) -> Syntax: - if _is_git_diff_based_scan(scan_type, command_scan_type): + if is_git_diff_based_scan(scan_type, command_scan_type): # it will return syntax with just one line return _get_code_snippet_syntax_from_git_diff(scan_type, detection, document, obfuscate) - return _get_code_snippet_syntax_from_file(scan_type, detection, document, lines_to_display, obfuscate) + return _get_code_snippet_syntax_from_file( + scan_type, detection, document, lines_to_display_before, lines_to_display_after, obfuscate + ) diff --git a/cycode/cli/printers/utils/detection_data.py b/cycode/cli/printers/utils/detection_data.py index 358b4c63..989a6600 100644 --- a/cycode/cli/printers/utils/detection_data.py +++ b/cycode/cli/printers/utils/detection_data.py @@ -35,9 +35,21 @@ def get_cwe_cve_link(cwe_cve: Optional[str]) -> Optional[str]: return None +def clear_cwe_name(cwe: str) -> str: + """Clear CWE. + + Intput: CWE-532: Insertion of Sensitive Information into Log File + Output: CWE-532 + """ + if cwe.startswith('CWE'): + return cwe.split(':')[0] + + return cwe + + def get_detection_clickable_cwe_cve(scan_type: str, detection: 'Detection') -> str: def link(url: str, name: str) -> str: - return f'[link={url}]{name}[/]' + return f'[link={url}]{clear_cwe_name(name)}[/]' if scan_type == consts.SCA_SCAN_TYPE: cve = detection.detection_details.get('vulnerability_id') @@ -84,5 +96,13 @@ def get_detection_file_path(scan_type: str, detection: 'Detection') -> Path: folder_path = detection.detection_details.get('file_path', '') file_name = detection.detection_details.get('file_name', '') return Path.joinpath(Path(folder_path), Path(file_name)) + if scan_type == consts.SAST_SCAN_TYPE: + file_path = detection.detection_details.get('file_path', '') + + # fix the absolute path...BE returns string which does not start with / + if not file_path.startswith('/'): + file_path = f'/{file_path}' + + return Path(file_path) return Path(detection.detection_details.get('file_name', '')) diff --git a/cycode/cli/utils/get_api_client.py b/cycode/cli/utils/get_api_client.py index 91e8f0f7..110d528b 100644 --- a/cycode/cli/utils/get_api_client.py +++ b/cycode/cli/utils/get_api_client.py @@ -6,6 +6,8 @@ from cycode.cyclient.client_creator import create_report_client, create_scan_client if TYPE_CHECKING: + import typer + from cycode.cyclient.report_client import ReportClient from cycode.cyclient.scan_client import ScanClient @@ -23,15 +25,16 @@ def _get_cycode_client( return create_client_func(client_id, client_secret, hide_response_log) -def get_scan_cycode_client( - client_id: Optional[str] = None, client_secret: Optional[str] = None, hide_response_log: bool = True -) -> 'ScanClient': +def get_scan_cycode_client(ctx: 'typer.Context') -> 'ScanClient': + client_id = ctx.obj.get('client_id') + client_secret = ctx.obj.get('client_secret') + hide_response_log = not ctx.obj.get('show_secret', False) return _get_cycode_client(create_scan_client, client_id, client_secret, hide_response_log) -def get_report_cycode_client( - client_id: Optional[str] = None, client_secret: Optional[str] = None, hide_response_log: bool = True -) -> 'ReportClient': +def get_report_cycode_client(ctx: 'typer.Context', hide_response_log: bool = True) -> 'ReportClient': + client_id = ctx.obj.get('client_id') + client_secret = ctx.obj.get('client_secret') return _get_cycode_client(create_report_client, client_id, client_secret, hide_response_log) diff --git a/cycode/cli/utils/version_checker.py b/cycode/cli/utils/version_checker.py index 47da17c4..8fd1d005 100644 --- a/cycode/cli/utils/version_checker.py +++ b/cycode/cli/utils/version_checker.py @@ -86,7 +86,9 @@ def get_latest_version(self) -> Optional[str]: """ try: - response = self.get(f'{self.PYPI_PACKAGE_NAME}/json', timeout=self.PYPI_REQUEST_TIMEOUT) + response = self.get( + f'{self.PYPI_PACKAGE_NAME}/json', timeout=self.PYPI_REQUEST_TIMEOUT, hide_response_content_log=True + ) data = response.json() return data.get('info', {}).get('version') except Exception: @@ -203,10 +205,10 @@ def check_and_notify_update(self, current_version: str, use_cache: bool = True) should_update = bool(latest_version) if should_update: update_message = ( - '\nNew version of cycode available! ' - f'[yellow]{current_version}[/] → [bright_blue]{latest_version}[/]\n' + '\nNew release of Cycode CLI is available: ' + f'[red]{current_version}[/] -> [green]{latest_version}[/]\n' f'Changelog: [bright_blue]{self.GIT_CHANGELOG_URL_PREFIX}{latest_version}[/]\n' - f'Run [green]pip install --upgrade cycode[/] to update\n' + f'To update, run: [green]pip install --upgrade cycode[/]\n' ) console.print(update_message) diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index e0bf8131..bdbce37f 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -1,5 +1,4 @@ import json -from copy import deepcopy from typing import TYPE_CHECKING, Union from uuid import UUID @@ -74,11 +73,6 @@ def zipped_file_scan_sync( is_git_diff: bool = False, ) -> models.ScanResultsSyncFlow: files = {'file': ('multiple_files_scan.zip', zip_file.read())} - - scan_parameters = deepcopy(scan_parameters) # avoid mutating the original dict - if 'report' in scan_parameters: - del scan_parameters['report'] # BE raises validation error instead of ignoring it - response = self.scan_cycode_client.post( url_path=self.get_zipped_file_scan_sync_url_path(scan_type), data={ diff --git a/tests/cli/commands/test_check_latest_version_on_close.py b/tests/cli/commands/test_check_latest_version_on_close.py index b1f11e24..eccadf93 100644 --- a/tests/cli/commands/test_check_latest_version_on_close.py +++ b/tests/cli/commands/test_check_latest_version_on_close.py @@ -10,7 +10,7 @@ from tests.conftest import CLI_ENV_VARS _NEW_LATEST_VERSION = '999.0.0' # Simulate a newer version available -_UPDATE_MESSAGE_PART = 'new version of cycode available' +_UPDATE_MESSAGE_PART = 'new release of cycode cli is available' @patch.object(VersionChecker, 'check_for_update') diff --git a/tests/test_code_scanner.py b/tests/test_code_scanner.py index 9ef09123..9372ede0 100644 --- a/tests/test_code_scanner.py +++ b/tests/test_code_scanner.py @@ -6,7 +6,7 @@ from cycode.cli import consts from cycode.cli.apps.scan.code_scanner import ( - _try_get_aggregation_report_url_if_needed, + _try_get_aggregation_report_url, ) from cycode.cli.cli_types import ScanTypeOption from cycode.cli.files_collector.excluder import _is_relevant_file_to_scan @@ -29,7 +29,7 @@ def test_try_get_aggregation_report_url_if_no_report_command_needed_return_none( ) -> None: aggregation_id = uuid4().hex scan_parameter = {'aggregation_id': aggregation_id} - result = _try_get_aggregation_report_url_if_needed(scan_parameter, scan_client, scan_type) + result = _try_get_aggregation_report_url(scan_parameter, scan_client, scan_type) assert result is None @@ -37,8 +37,7 @@ def test_try_get_aggregation_report_url_if_no_report_command_needed_return_none( def test_try_get_aggregation_report_url_if_no_aggregation_id_needed_return_none( scan_type: ScanTypeOption, scan_client: ScanClient ) -> None: - scan_parameter = {'report': True} - result = _try_get_aggregation_report_url_if_needed(scan_parameter, scan_client, scan_type) + result = _try_get_aggregation_report_url({}, scan_client, scan_type) assert result is None @@ -48,12 +47,12 @@ def test_try_get_aggregation_report_url_if_needed_return_result( scan_type: ScanTypeOption, scan_client: ScanClient, api_token_response: responses.Response ) -> None: aggregation_id = uuid4() - scan_parameter = {'report': True, 'aggregation_id': aggregation_id} + scan_parameter = {'aggregation_id': aggregation_id} url = get_scan_aggregation_report_url(aggregation_id, scan_client, scan_type) responses.add(api_token_response) # mock token based client responses.add(get_scan_aggregation_report_url_response(url, aggregation_id)) scan_aggregation_report_url_response = scan_client.get_scan_aggregation_report_url(str(aggregation_id), scan_type) - result = _try_get_aggregation_report_url_if_needed(scan_parameter, scan_client, scan_type) + result = _try_get_aggregation_report_url(scan_parameter, scan_client, scan_type) assert result == scan_aggregation_report_url_response.report_url From cdf571672f27e7f1cb282fe80c1be9f6aec7ca4e Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 6 May 2025 17:20:51 +0200 Subject: [PATCH 166/257] CM-48074 - Return report option with new name `--cycode-report` (#306) --- README.md | 27 ++++++++++++++++++++++++--- cycode/cli/apps/scan/code_scanner.py | 10 +++++++--- cycode/cli/apps/scan/scan_command.py | 8 ++++++++ cycode/cyclient/scan_client.py | 6 ++++++ tests/test_code_scanner.py | 11 ++++++----- 5 files changed, 51 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 7966361b..82575d6f 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,10 @@ This guide walks you through both installation and usage. 1. [Options](#options) 1. [Severity Threshold](#severity-option) 2. [Monitor](#monitor-option) - 3. [Package Vulnerabilities](#package-vulnerabilities-option) - 4. [License Compliance](#license-compliance-option) - 5. [Lock Restore](#lock-restore-option) + 3. [Cycode Report](#cycode-report-option) + 4. [Package Vulnerabilities](#package-vulnerabilities-option) + 5. [License Compliance](#license-compliance-option) + 6. [Lock Restore](#lock-restore-option) 2. [Repository Scan](#repository-scan) 1. [Branch Option](#branch-option) 3. [Path Scan](#path-scan) @@ -300,6 +301,7 @@ The Cycode CLI application offers several types of scans so that you can choose | `--severity-threshold [INFO\|LOW\|MEDIUM\|HIGH\|CRITICAL]` | Show only violations at the specified level or higher. | | `--sca-scan` | Specify the SCA scan you wish to execute (`package-vulnerabilities`/`license-compliance`). The default is both. | | `--monitor` | When specified, the scan results will be recorded in the knowledge graph. Please note that when working in `monitor` mode, the knowledge graph will not be updated as a result of SCM events (Push, Repo creation). (Supported for SCA scan type only). | +| `--cycode-report` | When specified, displays a link to the scan report in the Cycode platform in the console output. | | `--no-restore` | When specified, Cycode will not run restore command. Will scan direct dependencies ONLY! | | `--gradle-all-sub-projects` | When specified, Cycode will run gradle restore command for all sub projects. Should run from root project directory ONLY! | | `--help` | Show options for given command. | @@ -337,6 +339,25 @@ When using this option, the scan results from this scan will appear in the knowl > [!WARNING] > You must be an `owner` or an `admin` in Cycode to view the knowledge graph page. +#### Cycode Report Option + +For every scan performed using the Cycode CLI, a report is automatically generated and its results are sent to Cycode. These results are tied to the relevant policies (e.g., [SCA policies](https://docs.cycode.com/docs/sca-policies) for Repository scans) within the Cycode platform. + +To have the direct URL to this Cycode report printed in your CLI output after the scan completes, add the argument `--cycode-report` to your scan command. + +`cycode scan --cycode-report repository ~/home/git/codebase` + +All scan results from the CLI will appear in the CLI Logs section of Cycode. If you included the `--cycode-report` flag in your command, a direct link to the specific report will be displayed in your terminal following the scan results. + +> [!WARNING] +> You must be an `owner` or an `admin` in Cycode to view this page. + +![cli-report](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/sca_report_url.png) + +The report page will look something like below: + +![](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/scan_details.png) + #### Package Vulnerabilities Option > [!NOTE] diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index 04aae2e3..a40a066e 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -323,7 +323,7 @@ def scan_documents( scan_batch_thread_func, scan_type, documents_to_scan, progress_bar=progress_bar ) - aggregation_report_url = _try_get_aggregation_report_url(scan_parameters, ctx.obj['client'], scan_type) + aggregation_report_url = _try_get_aggregation_report_url_if_needed(scan_parameters, ctx.obj['client'], scan_type) _set_aggregation_report_url(ctx, aggregation_report_url) progress_bar.set_section_length(ScanProgressBarSection.GENERATE_REPORT, 1) @@ -641,6 +641,7 @@ def parse_pre_receive_input() -> str: def _get_default_scan_parameters(ctx: typer.Context) -> dict: return { 'monitor': ctx.obj.get('monitor'), + 'report': ctx.obj.get('report'), 'package_vulnerabilities': ctx.obj.get('package-vulnerabilities'), 'license_compliance': ctx.obj.get('license-compliance'), 'command_type': ctx.info_name.replace('-', '_'), # save backward compatibility @@ -956,7 +957,7 @@ def _get_scan_result( did_detect=True, detections_per_file=_map_detections_per_file_and_commit_id(scan_type, scan_raw_detections), scan_id=scan_id, - report_url=_try_get_aggregation_report_url(scan_parameters, cycode_client, scan_type), + report_url=_try_get_aggregation_report_url_if_needed(scan_parameters, cycode_client, scan_type), ) @@ -972,9 +973,12 @@ def _set_aggregation_report_url(ctx: typer.Context, aggregation_report_url: Opti ctx.obj['aggregation_report_url'] = aggregation_report_url -def _try_get_aggregation_report_url( +def _try_get_aggregation_report_url_if_needed( scan_parameters: dict, cycode_client: 'ScanClient', scan_type: str ) -> Optional[str]: + if not scan_parameters.get('report', False): + return None + aggregation_id = scan_parameters.get('aggregation_id') if aggregation_id is None: return None diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 2d323706..a2ffb550 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -45,6 +45,13 @@ def scan_command( '--sync', help='Run scan synchronously (INTERNAL FOR IDEs).', show_default='asynchronously', hidden=True ), ] = False, + report: Annotated[ + bool, + typer.Option( + '--cycode-report', + help='When specified, displays a link to the scan report in the Cycode platform in the console output.', + ), + ] = False, show_secret: Annotated[ bool, typer.Option('--show-secret', help='Show Secrets in plain text.', rich_help_panel=_SECRET_RICH_HELP_PANEL) ] = False, @@ -136,6 +143,7 @@ def scan_command( ctx.obj['sync'] = sync ctx.obj['severity_threshold'] = severity_threshold ctx.obj['monitor'] = monitor + ctx.obj['report'] = report if export_type and export_file: console_printer = ctx.obj['console_printer'] diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index bdbce37f..e0bf8131 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -1,4 +1,5 @@ import json +from copy import deepcopy from typing import TYPE_CHECKING, Union from uuid import UUID @@ -73,6 +74,11 @@ def zipped_file_scan_sync( is_git_diff: bool = False, ) -> models.ScanResultsSyncFlow: files = {'file': ('multiple_files_scan.zip', zip_file.read())} + + scan_parameters = deepcopy(scan_parameters) # avoid mutating the original dict + if 'report' in scan_parameters: + del scan_parameters['report'] # BE raises validation error instead of ignoring it + response = self.scan_cycode_client.post( url_path=self.get_zipped_file_scan_sync_url_path(scan_type), data={ diff --git a/tests/test_code_scanner.py b/tests/test_code_scanner.py index 9372ede0..9ef09123 100644 --- a/tests/test_code_scanner.py +++ b/tests/test_code_scanner.py @@ -6,7 +6,7 @@ from cycode.cli import consts from cycode.cli.apps.scan.code_scanner import ( - _try_get_aggregation_report_url, + _try_get_aggregation_report_url_if_needed, ) from cycode.cli.cli_types import ScanTypeOption from cycode.cli.files_collector.excluder import _is_relevant_file_to_scan @@ -29,7 +29,7 @@ def test_try_get_aggregation_report_url_if_no_report_command_needed_return_none( ) -> None: aggregation_id = uuid4().hex scan_parameter = {'aggregation_id': aggregation_id} - result = _try_get_aggregation_report_url(scan_parameter, scan_client, scan_type) + result = _try_get_aggregation_report_url_if_needed(scan_parameter, scan_client, scan_type) assert result is None @@ -37,7 +37,8 @@ def test_try_get_aggregation_report_url_if_no_report_command_needed_return_none( def test_try_get_aggregation_report_url_if_no_aggregation_id_needed_return_none( scan_type: ScanTypeOption, scan_client: ScanClient ) -> None: - result = _try_get_aggregation_report_url({}, scan_client, scan_type) + scan_parameter = {'report': True} + result = _try_get_aggregation_report_url_if_needed(scan_parameter, scan_client, scan_type) assert result is None @@ -47,12 +48,12 @@ def test_try_get_aggregation_report_url_if_needed_return_result( scan_type: ScanTypeOption, scan_client: ScanClient, api_token_response: responses.Response ) -> None: aggregation_id = uuid4() - scan_parameter = {'aggregation_id': aggregation_id} + scan_parameter = {'report': True, 'aggregation_id': aggregation_id} url = get_scan_aggregation_report_url(aggregation_id, scan_client, scan_type) responses.add(api_token_response) # mock token based client responses.add(get_scan_aggregation_report_url_response(url, aggregation_id)) scan_aggregation_report_url_response = scan_client.get_scan_aggregation_report_url(str(aggregation_id), scan_type) - result = _try_get_aggregation_report_url(scan_parameter, scan_client, scan_type) + result = _try_get_aggregation_report_url_if_needed(scan_parameter, scan_client, scan_type) assert result == scan_aggregation_report_url_response.report_url From 3575a218ab2033fd497d9bd803c8e22b1933f3ad Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Fri, 9 May 2025 10:43:34 +0200 Subject: [PATCH 167/257] CM-48211 - Update CODEOWNERS (#308) --- CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index 32a2011c..aba89cba 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1 +1 @@ -* @MarshalX @MichalBor @MaorDavidzon @artem-fedorov @elsapet @gotbadger @cfabianski +* @MarshalX @elsapet @gotbadger @cfabianski From 4e1f7e094548b2a68874ac1e52bfb131d497e712 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Fri, 9 May 2025 10:44:17 +0200 Subject: [PATCH 168/257] CM-48075 - Update pre-commit hook to work with compact output (#307) --- .pre-commit-hooks.yaml | 4 +- README.md | 10 +-- cycode/cli/apps/scan/code_scanner.py | 29 +++--- .../scan/pre_commit/pre_commit_command.py | 6 +- .../scan/repository/repository_command.py | 9 +- cycode/cli/cli_types.py | 22 +++-- .../files_collector/repository_documents.py | 37 ++++---- .../files_collector/sca/sca_code_scanner.py | 18 ++-- cycode/cli/printers/console_printer.py | 3 +- cycode/cli/printers/rich_printer.py | 89 ++++++++++++------- cycode/cli/printers/text_printer.py | 27 +++++- cycode/cli/printers/utils/detection_data.py | 2 +- cycode/cyclient/models.py | 9 ++ 13 files changed, 172 insertions(+), 93 deletions(-) diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 02a86db0..ab69bf3f 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -3,10 +3,10 @@ language: python language_version: python3 entry: cycode - args: [ '--no-progress-meter', 'scan', '--scan-type', 'secret', 'pre-commit' ] + args: [ '-o', 'text', '--no-progress-meter', 'scan', '-t', 'secret', 'pre-commit' ] - id: cycode-sca name: Cycode SCA pre-commit defender language: python language_version: python3 entry: cycode - args: [ '--no-progress-meter', 'scan', '--scan-type', 'sca', 'pre-commit' ] + args: [ '-o', 'text', '--no-progress-meter', 'scan', '-t', 'sca', 'pre-commit' ] diff --git a/README.md b/README.md index 82575d6f..13e23a6f 100644 --- a/README.md +++ b/README.md @@ -221,11 +221,11 @@ Perform the following steps to install the pre-commit hook: ```yaml repos: - repo: https://github.com/cycodehq/cycode-cli - rev: v2.3.0 + rev: v3.0.0 hooks: - id: cycode stages: - - commit + - pre-commit ``` 4. Modify the created file for your specific needs. Use hook ID `cycode` to enable scan for Secrets. Use hook ID `cycode-sca` to enable SCA scan. If you want to enable both, use this configuration: @@ -233,14 +233,14 @@ Perform the following steps to install the pre-commit hook: ```yaml repos: - repo: https://github.com/cycodehq/cycode-cli - rev: v2.3.0 + rev: v3.0.0 hooks: - id: cycode stages: - - commit + - pre-commit - id: cycode-sca stages: - - commit + - pre-commit ``` 5. Install Cycode’s hook: diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index a40a066e..c6337021 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -48,15 +48,17 @@ logger = get_logger('Code Scanner') -def scan_sca_pre_commit(ctx: typer.Context) -> None: +def scan_sca_pre_commit(ctx: typer.Context, repo_path: str) -> None: scan_type = ctx.obj['scan_type'] scan_parameters = get_scan_parameters(ctx) git_head_documents, pre_committed_documents = get_pre_commit_modified_documents( - ctx.obj['progress_bar'], ScanProgressBarSection.PREPARE_LOCAL_FILES + progress_bar=ctx.obj['progress_bar'], + progress_bar_section=ScanProgressBarSection.PREPARE_LOCAL_FILES, + repo_path=repo_path, ) git_head_documents = exclude_irrelevant_documents_to_scan(scan_type, git_head_documents) pre_committed_documents = exclude_irrelevant_documents_to_scan(scan_type, pre_committed_documents) - sca_code_scanner.perform_pre_hook_range_scan_actions(git_head_documents, pre_committed_documents) + sca_code_scanner.perform_pre_hook_range_scan_actions(repo_path, git_head_documents, pre_committed_documents) scan_commit_range_documents( ctx, git_head_documents, @@ -269,14 +271,13 @@ def scan_commit_range( commit_id = commit.hexsha commit_ids_to_scan.append(commit_id) parent = commit.parents[0] if commit.parents else git_proxy.get_null_tree() - diff = commit.diff(parent, create_patch=True, R=True) + diff_index = commit.diff(parent, create_patch=True, R=True) commit_documents_to_scan = [] - for blob in diff: - blob_path = get_path_by_os(os.path.join(path, get_diff_file_path(blob))) + for diff in diff_index: commit_documents_to_scan.append( Document( - path=blob_path, - content=blob.diff.decode('UTF-8', errors='replace'), + path=get_path_by_os(get_diff_file_path(diff)), + content=diff.diff.decode('UTF-8', errors='replace'), is_git_diff_format=True, unique_id=commit_id, ) @@ -413,10 +414,10 @@ def scan_commit_range_documents( _report_scan_status( cycode_client, scan_type, - local_scan_result.scan_id, + scan_id, scan_completed, - local_scan_result.relevant_detections_count, - local_scan_result.detections_count, + relevant_detections_count, + detections_count, len(to_documents_to_scan), zip_file_size, scan_command_type, @@ -658,7 +659,11 @@ def get_scan_parameters(ctx: typer.Context, paths: Optional[tuple[str, ...]] = N scan_parameters['paths'] = paths if len(paths) != 1: - # ignore remote url if multiple paths are provided + logger.debug('Multiple paths provided, going to ignore remote url') + return scan_parameters + + if not os.path.isdir(paths[0]): + logger.debug('Path is not a directory, going to ignore remote url') return scan_parameters remote_url = try_get_git_remote_url(paths[0]) diff --git a/cycode/cli/apps/scan/pre_commit/pre_commit_command.py b/cycode/cli/apps/scan/pre_commit/pre_commit_command.py index b919d659..40e6a8c1 100644 --- a/cycode/cli/apps/scan/pre_commit/pre_commit_command.py +++ b/cycode/cli/apps/scan/pre_commit/pre_commit_command.py @@ -27,14 +27,16 @@ def pre_commit_command( scan_type = ctx.obj['scan_type'] + repo_path = os.getcwd() # change locally for easy testing + progress_bar = ctx.obj['progress_bar'] progress_bar.start() if scan_type == consts.SCA_SCAN_TYPE: - scan_sca_pre_commit(ctx) + scan_sca_pre_commit(ctx, repo_path) return - diff_files = git_proxy.get_repo(os.getcwd()).index.diff('HEAD', create_patch=True, R=True) + diff_files = git_proxy.get_repo(repo_path).index.diff(consts.GIT_HEAD_COMMIT_REV, create_patch=True, R=True) progress_bar.set_section_length(ScanProgressBarSection.PREPARE_LOCAL_FILES, len(diff_files)) diff --git a/cycode/cli/apps/scan/repository/repository_command.py b/cycode/cli/apps/scan/repository/repository_command.py index 16ad8611..c96ca577 100644 --- a/cycode/cli/apps/scan/repository/repository_command.py +++ b/cycode/cli/apps/scan/repository/repository_command.py @@ -1,4 +1,3 @@ -import os from pathlib import Path from typing import Annotated, Optional @@ -44,16 +43,16 @@ def repository_command( progress_bar.set_section_length(ScanProgressBarSection.PREPARE_LOCAL_FILES, len(file_entries)) documents_to_scan = [] - for file in file_entries: + for blob in file_entries: # FIXME(MarshalX): probably file could be tree or submodule too. we expect blob only progress_bar.update(ScanProgressBarSection.PREPARE_LOCAL_FILES) - absolute_path = get_path_by_os(os.path.join(path, file.path)) - file_path = file.path if monitor else absolute_path + absolute_path = get_path_by_os(blob.abspath) + file_path = get_path_by_os(blob.path) if monitor else absolute_path documents_to_scan.append( Document( file_path, - file.data_stream.read().decode('UTF-8', errors='replace'), + blob.data_stream.read().decode('UTF-8', errors='replace'), absolute_path=absolute_path, ) ) diff --git a/cycode/cli/cli_types.py b/cycode/cli/cli_types.py index 9b792a01..c2fa12a2 100644 --- a/cycode/cli/cli_types.py +++ b/cycode/cli/cli_types.py @@ -3,42 +3,50 @@ from cycode.cli import consts -class OutputTypeOption(str, Enum): +class StrEnum(str, Enum): + def __str__(self) -> str: + return self.value + + +class OutputTypeOption(StrEnum): RICH = 'rich' TEXT = 'text' JSON = 'json' TABLE = 'table' -class ExportTypeOption(str, Enum): +class ExportTypeOption(StrEnum): JSON = 'json' HTML = 'html' SVG = 'svg' -class ScanTypeOption(str, Enum): +class ScanTypeOption(StrEnum): SECRET = consts.SECRET_SCAN_TYPE SCA = consts.SCA_SCAN_TYPE IAC = consts.IAC_SCAN_TYPE SAST = consts.SAST_SCAN_TYPE + def __str__(self) -> str: + return self.value + -class ScaScanTypeOption(str, Enum): +class ScaScanTypeOption(StrEnum): PACKAGE_VULNERABILITIES = 'package-vulnerabilities' LICENSE_COMPLIANCE = 'license-compliance' -class SbomFormatOption(str, Enum): +class SbomFormatOption(StrEnum): SPDX_2_2 = 'spdx-2.2' SPDX_2_3 = 'spdx-2.3' CYCLONEDX_1_4 = 'cyclonedx-1.4' -class SbomOutputFormatOption(str, Enum): +class SbomOutputFormatOption(StrEnum): JSON = 'json' -class SeverityOption(str, Enum): +class SeverityOption(StrEnum): INFO = 'info' LOW = 'low' MEDIUM = 'medium' diff --git a/cycode/cli/files_collector/repository_documents.py b/cycode/cli/files_collector/repository_documents.py index b524ca4c..379346f8 100644 --- a/cycode/cli/files_collector/repository_documents.py +++ b/cycode/cli/files_collector/repository_documents.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Optional, Union from cycode.cli import consts -from cycode.cli.files_collector.sca import sca_code_scanner +from cycode.cli.files_collector.sca.sca_code_scanner import get_file_content_from_commit_diff from cycode.cli.models import Document from cycode.cli.utils.git_proxy import git_proxy from cycode.cli.utils.path_utils import get_file_content, get_path_by_os @@ -38,8 +38,14 @@ def parse_commit_range(commit_range: str, path: str) -> tuple[str, str]: return from_commit_rev, to_commit_rev -def get_diff_file_path(file: 'Diff') -> Optional[str]: - return file.b_path if file.b_path else file.a_path +def get_diff_file_path(file: 'Diff', relative: bool = False) -> Optional[str]: + if relative: + # relative to the repository root + return file.b_path if file.b_path else file.a_path + + if file.b_blob: + return file.b_blob.abspath + return file.a_blob.abspath def get_diff_file_content(file: 'Diff') -> str: @@ -47,21 +53,21 @@ def get_diff_file_content(file: 'Diff') -> str: def get_pre_commit_modified_documents( - progress_bar: 'BaseProgressBar', progress_bar_section: 'ProgressBarSection' + progress_bar: 'BaseProgressBar', + progress_bar_section: 'ProgressBarSection', + repo_path: str, ) -> tuple[list[Document], list[Document]]: git_head_documents = [] pre_committed_documents = [] - repo = git_proxy.get_repo(os.getcwd()) - diff_files = repo.index.diff(consts.GIT_HEAD_COMMIT_REV, create_patch=True, R=True) - progress_bar.set_section_length(progress_bar_section, len(diff_files)) - for file in diff_files: + repo = git_proxy.get_repo(repo_path) + diff_index = repo.index.diff(consts.GIT_HEAD_COMMIT_REV, create_patch=True, R=True) + progress_bar.set_section_length(progress_bar_section, len(diff_index)) + for diff in diff_index: progress_bar.update(progress_bar_section) - diff_file_path = get_diff_file_path(file) - file_path = get_path_by_os(diff_file_path) - - file_content = sca_code_scanner.get_file_content_from_commit(repo, consts.GIT_HEAD_COMMIT_REV, diff_file_path) + file_path = get_path_by_os(get_diff_file_path(diff)) + file_content = get_file_content_from_commit_diff(repo, consts.GIT_HEAD_COMMIT_REV, diff) if file_content is not None: git_head_documents.append(Document(file_path, file_content)) @@ -92,14 +98,13 @@ def get_commit_range_modified_documents( for blob in modified_files_diff: progress_bar.update(progress_bar_section) - diff_file_path = get_diff_file_path(blob) - file_path = get_path_by_os(diff_file_path) + file_path = get_path_by_os(get_diff_file_path(blob)) - file_content = sca_code_scanner.get_file_content_from_commit(repo, from_commit_rev, diff_file_path) + file_content = get_file_content_from_commit_diff(repo, from_commit_rev, blob) if file_content is not None: from_commit_documents.append(Document(file_path, file_content)) - file_content = sca_code_scanner.get_file_content_from_commit(repo, to_commit_rev, diff_file_path) + file_content = get_file_content_from_commit_diff(repo, to_commit_rev, blob) if file_content is not None: to_commit_documents.append(Document(file_path, file_content)) diff --git a/cycode/cli/files_collector/sca/sca_code_scanner.py b/cycode/cli/files_collector/sca/sca_code_scanner.py index e6ec0e9d..b9988122 100644 --- a/cycode/cli/files_collector/sca/sca_code_scanner.py +++ b/cycode/cli/files_collector/sca/sca_code_scanner.py @@ -1,4 +1,3 @@ -import os from typing import TYPE_CHECKING, Optional import typer @@ -18,7 +17,7 @@ from cycode.logger import get_logger if TYPE_CHECKING: - from git import Repo + from git import Diff, Repo BUILD_DEP_TREE_TIMEOUT = 180 @@ -39,9 +38,9 @@ def perform_pre_commit_range_scan_actions( def perform_pre_hook_range_scan_actions( - git_head_documents: list[Document], pre_committed_documents: list[Document] + repo_path: str, git_head_documents: list[Document], pre_committed_documents: list[Document] ) -> None: - repo = git_proxy.get_repo(os.getcwd()) + repo = git_proxy.get_repo(repo_path) add_ecosystem_related_files_if_exists(git_head_documents, repo, consts.GIT_HEAD_COMMIT_REV) add_ecosystem_related_files_if_exists(pre_committed_documents) @@ -69,7 +68,7 @@ def get_doc_ecosystem_related_project_files( file_to_search = join_paths(get_file_dir(doc.path), ecosystem_project_file) if not is_project_file_exists_in_documents(documents, file_to_search): if repo: - file_content = get_file_content_from_commit(repo, commit_rev, file_to_search) + file_content = get_file_content_from_commit_path(repo, commit_rev, file_to_search) else: file_content = get_file_content(file_to_search) @@ -151,13 +150,20 @@ def get_manifest_file_path(document: Document, is_monitor_action: bool, project_ return join_paths(project_path, document.path) if is_monitor_action else document.path -def get_file_content_from_commit(repo: 'Repo', commit: str, file_path: str) -> Optional[str]: +def get_file_content_from_commit_path(repo: 'Repo', commit: str, file_path: str) -> Optional[str]: try: return repo.git.show(f'{commit}:{file_path}') except git_proxy.get_git_command_error(): return None +def get_file_content_from_commit_diff(repo: 'Repo', commit: str, diff: 'Diff') -> Optional[str]: + from cycode.cli.files_collector.repository_documents import get_diff_file_path + + file_path = get_diff_file_path(diff, relative=True) + return get_file_content_from_commit_path(repo, commit, file_path) + + def perform_pre_scan_documents_actions( ctx: typer.Context, scan_type: str, documents_to_scan: list[Document], is_git_diff: bool = False ) -> None: diff --git a/cycode/cli/printers/console_printer.py b/cycode/cli/printers/console_printer.py index 17c402ff..50d48fd7 100644 --- a/cycode/cli/printers/console_printer.py +++ b/cycode/cli/printers/console_printer.py @@ -28,9 +28,8 @@ class ConsolePrinter: 'text': TextPrinter, 'json': JsonPrinter, 'table': TablePrinter, - # overrides + # overrides: 'table_sca': ScaTablePrinter, - 'text_sca': ScaTablePrinter, } def __init__( diff --git a/cycode/cli/printers/rich_printer.py b/cycode/cli/printers/rich_printer.py index 755278d6..7ee0f853 100644 --- a/cycode/cli/printers/rich_printer.py +++ b/cycode/cli/printers/rich_printer.py @@ -54,47 +54,69 @@ def _get_details_table(self, detection: 'Detection') -> Table: severity_icon = SeverityOption.get_member_emoji(severity.lower()) details_table.add_row('Severity', f'{severity_icon} {SeverityOption(severity).__rich__()}') - detection_details = detection.detection_details - path = str(get_detection_file_path(self.scan_type, detection)) shorten_path = f'...{path[-self.MAX_PATH_LENGTH :]}' if len(path) > self.MAX_PATH_LENGTH else path details_table.add_row('In file', f'[link=file://{path}]{shorten_path}[/]') - if self.scan_type == consts.SECRET_SCAN_TYPE: - details_table.add_row('Secret SHA', detection_details.get('sha512')) - elif self.scan_type == consts.SCA_SCAN_TYPE: - details_table.add_row('CVEs', get_detection_clickable_cwe_cve(self.scan_type, detection)) - details_table.add_row('Package', detection_details.get('package_name')) - details_table.add_row('Version', detection_details.get('package_version')) - - is_package_vulnerability = 'alert' in detection_details - if is_package_vulnerability: - details_table.add_row( - 'First patched version', detection_details['alert'].get('first_patched_version', 'Not fixed') - ) - - details_table.add_row('Dependency path', detection_details.get('dependency_paths', 'N/A')) - - if not is_package_vulnerability: - details_table.add_row('License', detection_details.get('license')) - elif self.scan_type == consts.IAC_SCAN_TYPE: - details_table.add_row('IaC Provider', detection_details.get('infra_provider')) - elif self.scan_type == consts.SAST_SCAN_TYPE: - details_table.add_row('CWE', get_detection_clickable_cwe_cve(self.scan_type, detection)) - details_table.add_row('Subcategory', detection_details.get('category')) - details_table.add_row('Language', ', '.join(detection_details.get('languages', []))) - - engine_id_to_display_name = { - '5db84696-88dc-11ec-a8a3-0242ac120002': 'Semgrep OSS (Orchestrated by Cycode)', - '560a0abd-d7da-4e6d-a3f1-0ed74895295c': 'Bearer (Powered by Cycode)', - } - engine_id = detection.detection_details.get('external_scanner_id') - details_table.add_row('Security Tool', engine_id_to_display_name.get(engine_id, 'N/A')) + self._add_scan_related_rows(details_table, detection) details_table.add_row('Rule ID', detection.detection_rule_id) return details_table + def _add_scan_related_rows(self, details_table: Table, detection: 'Detection') -> None: + scan_type_details_handlers = { + consts.SECRET_SCAN_TYPE: self.__add_secret_scan_related_rows, + consts.SCA_SCAN_TYPE: self.__add_sca_scan_related_rows, + consts.IAC_SCAN_TYPE: self.__add_iac_scan_related_rows, + consts.SAST_SCAN_TYPE: self.__add_sast_scan_related_rows, + } + + if self.scan_type not in scan_type_details_handlers: + raise ValueError(f'Unknown scan type: {self.scan_type}') + + scan_enricher_function = scan_type_details_handlers[self.scan_type] + scan_enricher_function(details_table, detection) + + @staticmethod + def __add_secret_scan_related_rows(details_table: Table, detection: 'Detection') -> None: + details_table.add_row('Secret SHA', detection.detection_details.get('sha512')) + + @staticmethod + def __add_sca_scan_related_rows(details_table: Table, detection: 'Detection') -> None: + detection_details = detection.detection_details + + details_table.add_row('CVEs', get_detection_clickable_cwe_cve(consts.SCA_SCAN_TYPE, detection)) + details_table.add_row('Package', detection_details.get('package_name')) + details_table.add_row('Version', detection_details.get('package_version')) + + if detection.has_alert: + patched_version = detection_details['alert'].get('patched_version') + details_table.add_row('First patched version', patched_version or 'Not fixed') + + dependency_path = detection_details.get('dependency_paths') + details_table.add_row('Dependency path', dependency_path or 'N/A') + + if not detection.has_alert: + details_table.add_row('License', detection_details.get('license')) + + @staticmethod + def __add_iac_scan_related_rows(details_table: Table, detection: 'Detection') -> None: + details_table.add_row('IaC Provider', detection.detection_details.get('infra_provider')) + + @staticmethod + def __add_sast_scan_related_rows(details_table: Table, detection: 'Detection') -> None: + details_table.add_row('CWE', get_detection_clickable_cwe_cve(consts.SAST_SCAN_TYPE, detection)) + details_table.add_row('Subcategory', detection.detection_details.get('category')) + details_table.add_row('Language', ', '.join(detection.detection_details.get('languages', []))) + + engine_id_to_display_name = { + '5db84696-88dc-11ec-a8a3-0242ac120002': 'Semgrep OSS (Orchestrated by Cycode)', + '560a0abd-d7da-4e6d-a3f1-0ed74895295c': 'Bearer (Powered by Cycode)', + } + engine_id = detection.detection_details.get('external_scanner_id') + details_table.add_row('Security Tool', engine_id_to_display_name.get(engine_id, 'N/A')) + def _print_violation_card( self, document: 'Document', detection: 'Detection', detection_number: int, detections_count: int ) -> None: @@ -117,8 +139,7 @@ def _print_violation_card( title=':computer: Code Snippet', ) - is_sca_package_vulnerability = self.scan_type == consts.SCA_SCAN_TYPE and 'alert' in detection.detection_details - if is_sca_package_vulnerability: + if detection.has_alert: summary = detection.detection_details['alert'].get('description') else: summary = detection.detection_details.get('description') or detection.message diff --git a/cycode/cli/printers/text_printer.py b/cycode/cli/printers/text_printer.py index 05a360fd..51da53c5 100644 --- a/cycode/cli/printers/text_printer.py +++ b/cycode/cli/printers/text_printer.py @@ -1,5 +1,6 @@ from typing import TYPE_CHECKING, Optional +from cycode.cli import consts from cycode.cli.cli_types import SeverityOption from cycode.cli.models import CliError, CliResult, Document from cycode.cli.printers.printer_base import PrinterBase @@ -66,10 +67,34 @@ def __print_detection_summary(self, detection: 'Detection', document_path: str) self.console.print( severity_icon, severity, - f'violation: [b bright_red]{title}[/]{detection_commit_id_message}\n' + f'violation: [b bright_red]{title}[/]{detection_commit_id_message}\n', + *self.__get_intermediate_summary_lines(detection), f'[dodger_blue1]File: {clickable_document_path}[/]', ) + def __get_intermediate_summary_lines(self, detection: 'Detection') -> list[str]: + intermediate_summary_lines = [] + + if self.scan_type == consts.SCA_SCAN_TYPE: + intermediate_summary_lines.extend(self.__get_sca_related_summary_lines(detection)) + + return intermediate_summary_lines + + @staticmethod + def __get_sca_related_summary_lines(detection: 'Detection') -> list[str]: + summary_lines = [] + + if detection.has_alert: + patched_version = detection.detection_details['alert'].get('first_patched_version') + patched_version = patched_version or 'Not fixed' + + summary_lines.append(f'First patched version: [cyan]{patched_version}[/]\n') + else: + package_license = detection.detection_details.get('license', 'N/A') + summary_lines.append(f'License: [cyan]{package_license}[/]\n') + + return summary_lines + def __print_detection_code_segment(self, detection: 'Detection', document: Document) -> None: self.console.print( get_code_snippet_syntax( diff --git a/cycode/cli/printers/utils/detection_data.py b/cycode/cli/printers/utils/detection_data.py index 989a6600..37bee310 100644 --- a/cycode/cli/printers/utils/detection_data.py +++ b/cycode/cli/printers/utils/detection_data.py @@ -83,7 +83,7 @@ def get_detection_title(scan_type: str, detection: 'Detection') -> str: elif scan_type == consts.SECRET_SCAN_TYPE: title = f'Hardcoded {detection.type} is used' - is_sca_package_vulnerability = scan_type == consts.SCA_SCAN_TYPE and 'alert' in detection.detection_details + is_sca_package_vulnerability = scan_type == consts.SCA_SCAN_TYPE and detection.has_alert if is_sca_package_vulnerability: title = detection.detection_details['alert'].get('summary', 'N/A') diff --git a/cycode/cyclient/models.py b/cycode/cyclient/models.py index 70e3e551..ed649644 100644 --- a/cycode/cyclient/models.py +++ b/cycode/cyclient/models.py @@ -33,6 +33,15 @@ def __repr__(self) -> str: f'detection_rule_id:{self.detection_rule_id}' ) + @property + def has_alert(self) -> bool: + """Check if the detection has an alert. + + For example, for SCA, it means that the detection is a package vulnerability. + Otherwise, it is a license. + """ + return 'alert' in self.detection_details + class DetectionSchema(Schema): class Meta: From b07c4332c6e2e30b11fce5dff9231cf6f4786bfd Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 14 May 2025 15:36:39 +0200 Subject: [PATCH 169/257] CM-48357 - Fix SCA restore error handling (#309) --- cycode/cli/apps/scan/code_scanner.py | 16 +++--- .../sca/base_restore_dependencies.py | 51 +++++++++---------- .../sca/go/restore_go_dependencies.py | 3 -- .../sca/maven/restore_gradle_dependencies.py | 11 ++-- .../sca/maven/restore_maven_dependencies.py | 26 +++++----- .../sca/npm/restore_npm_dependencies.py | 3 -- .../sca/nuget/restore_nuget_dependencies.py | 5 -- .../sca/ruby/restore_ruby_dependencies.py | 3 -- .../sca/sbt/restore_sbt_dependencies.py | 3 -- cycode/cli/utils/shell_executor.py | 8 ++- 10 files changed, 55 insertions(+), 74 deletions(-) diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index c6337021..e7dff93f 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -683,8 +683,8 @@ def try_get_git_remote_url(path: str) -> Optional[str]: remote_url = git_proxy.get_repo(path).remotes[0].config_reader.get('url') logger.debug('Found Git remote URL, %s', {'remote_url': remote_url, 'path': path}) return remote_url - except Exception as e: - logger.debug('Failed to get Git remote URL', exc_info=e) + except Exception: + logger.debug('Failed to get Git remote URL. Probably not a Git repository') return None @@ -706,7 +706,9 @@ def _get_plastic_repository_name(path: str) -> Optional[str]: f'--fieldseparator={consts.PLASTIC_VCS_DATA_SEPARATOR}', ] - status = shell(command=command, timeout=consts.PLASTIC_VSC_CLI_TIMEOUT, working_directory=path) + status = shell( + command=command, timeout=consts.PLASTIC_VSC_CLI_TIMEOUT, working_directory=path, silent_exc_info=True + ) if not status: logger.debug('Failed to get Plastic repository name (command failed)') return None @@ -717,8 +719,8 @@ def _get_plastic_repository_name(path: str) -> Optional[str]: return None return status_parts[2].strip() - except Exception as e: - logger.debug('Failed to get Plastic repository name', exc_info=e) + except Exception: + logger.debug('Failed to get Plastic repository name. Probably not a Plastic repository') return None @@ -738,7 +740,9 @@ def _get_plastic_repository_list(working_dir: Optional[str] = None) -> dict[str, try: command = ['cm', 'repo', 'ls', f'--format={{repname}}{consts.PLASTIC_VCS_DATA_SEPARATOR}{{repguid}}'] - status = shell(command=command, timeout=consts.PLASTIC_VSC_CLI_TIMEOUT, working_directory=working_dir) + status = shell( + command=command, timeout=consts.PLASTIC_VSC_CLI_TIMEOUT, working_directory=working_dir, silent_exc_info=True + ) if not status: logger.debug('Failed to get Plastic repository list (command failed)') return repo_name_to_guid diff --git a/cycode/cli/files_collector/sca/base_restore_dependencies.py b/cycode/cli/files_collector/sca/base_restore_dependencies.py index c4364c05..ea8a0bb7 100644 --- a/cycode/cli/files_collector/sca/base_restore_dependencies.py +++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py @@ -1,9 +1,9 @@ +import os from abc import ABC, abstractmethod from typing import Optional import typer -from cycode.cli.logger import logger from cycode.cli.models import Document from cycode.cli.utils.path_utils import get_file_content, get_file_dir, get_path_from_context, join_paths from cycode.cli.utils.shell_executor import shell @@ -15,30 +15,27 @@ def build_dep_tree_path(path: str, generated_file_name: str) -> str: def execute_commands( commands: list[list[str]], - file_name: str, - command_timeout: int, - dependencies_file_name: Optional[str] = None, + timeout: int, + output_file_path: Optional[str] = None, working_directory: Optional[str] = None, ) -> Optional[str]: try: - all_dependencies = [] + outputs = [] - # Run all commands and collect outputs for command in commands: - dependencies = shell(command=command, timeout=command_timeout, working_directory=working_directory) - all_dependencies.append(dependencies) # Collect each command's output + command_output = shell(command=command, timeout=timeout, working_directory=working_directory) + if command_output: + outputs.append(command_output) - dependencies = '\n'.join(all_dependencies) + joined_output = '\n'.join(outputs) - # Write all collected outputs to the file if dependencies_file_name is provided - if dependencies_file_name: - with open(dependencies_file_name, 'w') as output_file: # Open once in 'w' mode to start fresh - output_file.writelines(dependencies) - except Exception as e: - logger.debug('Failed to restore dependencies via shell command, %s', {'filename': file_name}, exc_info=e) + if output_file_path: + with open(output_file_path, 'w', encoding='UTF-8') as output_file: + output_file.writelines(joined_output) + except Exception: return None - return dependencies + return joined_output class BaseRestoreDependencies(ABC): @@ -64,27 +61,25 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: relative_restore_file_path = build_dep_tree_path(document.path, self.get_lock_file_name()) working_directory_path = self.get_working_directory(document) - if self.verify_restore_file_already_exist(restore_file_path): - restore_file_content = get_file_content(restore_file_path) - else: - output_file_path = restore_file_path if self.create_output_file_manually else None - execute_commands( + if not self.verify_restore_file_already_exist(restore_file_path): + output = execute_commands( self.get_commands(manifest_file_path), - manifest_file_path, self.command_timeout, - output_file_path, - working_directory_path, + output_file_path=restore_file_path if self.create_output_file_manually else None, + working_directory=working_directory_path, ) - restore_file_content = get_file_content(restore_file_path) + if output is None: # one of the commands failed + return None + restore_file_content = get_file_content(restore_file_path) return Document(relative_restore_file_path, restore_file_content, self.is_git_diff) def get_working_directory(self, document: Document) -> Optional[str]: return None - @abstractmethod - def verify_restore_file_already_exist(self, restore_file_path: str) -> bool: - pass + @staticmethod + def verify_restore_file_already_exist(restore_file_path: str) -> bool: + return os.path.isfile(restore_file_path) @abstractmethod def is_project(self, document: Document) -> bool: diff --git a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py index 4f469896..6eb48a76 100644 --- a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py +++ b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py @@ -44,8 +44,5 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: def get_lock_file_name(self) -> str: return GO_RESTORE_FILE_NAME - def verify_restore_file_already_exist(self, restore_file_path: str) -> bool: - return os.path.isfile(restore_file_path) - def get_working_directory(self, document: Document) -> Optional[str]: return os.path.dirname(document.absolute_path) diff --git a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py index 89595e0e..777ae727 100644 --- a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py @@ -42,22 +42,19 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: def get_lock_file_name(self) -> str: return BUILD_GRADLE_DEP_TREE_FILE_NAME - def verify_restore_file_already_exist(self, restore_file_path: str) -> bool: - return os.path.isfile(restore_file_path) - def get_working_directory(self, document: Document) -> Optional[str]: return get_path_from_context(self.ctx) if self.is_gradle_sub_projects() else None def get_all_projects(self) -> set[str]: - projects_output = shell( + output = shell( command=BUILD_GRADLE_ALL_PROJECTS_COMMAND, timeout=BUILD_GRADLE_ALL_PROJECTS_TIMEOUT, working_directory=get_path_from_context(self.ctx), ) + if not output: + return set() - projects = re.findall(ALL_PROJECTS_REGEX, projects_output) - - return set(projects) + return set(re.findall(ALL_PROJECTS_REGEX, output)) def get_commands_for_sub_projects(self, manifest_file_path: str) -> list[list[str]]: project_name = os.path.basename(os.path.dirname(manifest_file_path)) diff --git a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py index 1c3d860c..b9a2b1ed 100644 --- a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py @@ -1,4 +1,3 @@ -import os from os import path from typing import Optional @@ -30,9 +29,6 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: def get_lock_file_name(self) -> str: return join_paths('target', MAVEN_CYCLONE_DEP_TREE_FILE_NAME) - def verify_restore_file_already_exist(self, restore_file_path: str) -> bool: - return os.path.isfile(restore_file_path) - def try_restore_dependencies(self, document: Document) -> Optional[Document]: restore_dependencies_document = super().try_restore_dependencies(document) manifest_file_path = self.get_manifest_file_path(document) @@ -51,8 +47,8 @@ def restore_from_secondary_command( self, document: Document, manifest_file_path: str, restore_dependencies_document: Optional[Document] ) -> Optional[Document]: # TODO(MarshalX): does it even work? Ignored restore_dependencies_document arg - secondary_restore_command = create_secondary_restore_command(manifest_file_path) - backup_restore_content = execute_commands(secondary_restore_command, manifest_file_path, self.command_timeout) + secondary_restore_command = create_secondary_restore_commands(manifest_file_path) + backup_restore_content = execute_commands(secondary_restore_command, self.command_timeout) restore_dependencies_document = Document( build_dep_tree_path(document.path, MAVEN_DEP_TREE_FILE_NAME), backup_restore_content, self.is_git_diff ) @@ -64,13 +60,15 @@ def restore_from_secondary_command( return restore_dependencies -def create_secondary_restore_command(manifest_file_path: str) -> list[str]: +def create_secondary_restore_commands(manifest_file_path: str) -> list[list[str]]: return [ - 'mvn', - 'dependency:tree', - '-B', - '-DoutputType=text', - '-f', - manifest_file_path, - f'-DoutputFile={MAVEN_DEP_TREE_FILE_NAME}', + [ + 'mvn', + 'dependency:tree', + '-B', + '-DoutputType=text', + '-f', + manifest_file_path, + f'-DoutputFile={MAVEN_DEP_TREE_FILE_NAME}', + ] ] diff --git a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py index ed8e36c2..2563612f 100644 --- a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py +++ b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py @@ -33,9 +33,6 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: def get_lock_file_name(self) -> str: return NPM_LOCK_FILE_NAME - def verify_restore_file_already_exist(self, restore_file_path: str) -> bool: - return os.path.isfile(restore_file_path) - @staticmethod def prepare_manifest_file_path_for_command(manifest_file_path: str) -> str: return manifest_file_path.replace(os.sep + NPM_MANIFEST_FILE_NAME, '') diff --git a/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py b/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py index 3bd6627f..3035e206 100644 --- a/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py +++ b/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py @@ -1,5 +1,3 @@ -import os - import typer from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies @@ -21,6 +19,3 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: def get_lock_file_name(self) -> str: return NUGET_LOCK_FILE_NAME - - def verify_restore_file_already_exist(self, restore_file_path: str) -> bool: - return os.path.isfile(restore_file_path) diff --git a/cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py b/cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py index 4571b1c5..8c256f27 100644 --- a/cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py +++ b/cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py @@ -18,8 +18,5 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: def get_lock_file_name(self) -> str: return RUBY_LOCK_FILE_NAME - def verify_restore_file_already_exist(self, restore_file_path: str) -> bool: - return os.path.isfile(restore_file_path) - def get_working_directory(self, document: Document) -> Optional[str]: return os.path.dirname(document.absolute_path) diff --git a/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py b/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py index d7eeba3b..26a88646 100644 --- a/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py +++ b/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py @@ -18,8 +18,5 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: def get_lock_file_name(self) -> str: return SBT_LOCK_FILE_NAME - def verify_restore_file_already_exist(self, restore_file_path: str) -> bool: - return os.path.isfile(restore_file_path) - def get_working_directory(self, document: Document) -> Optional[str]: return os.path.dirname(document.absolute_path) diff --git a/cycode/cli/utils/shell_executor.py b/cycode/cli/utils/shell_executor.py index db0331da..2529890b 100644 --- a/cycode/cli/utils/shell_executor.py +++ b/cycode/cli/utils/shell_executor.py @@ -16,6 +16,7 @@ def shell( command: Union[str, list[str]], timeout: int = _SUBPROCESS_DEFAULT_TIMEOUT_SEC, working_directory: Optional[str] = None, + silent_exc_info: bool = False, ) -> Optional[str]: logger.debug('Executing shell command: %s', command) @@ -27,12 +28,15 @@ def shell( return result.stdout.decode('UTF-8').strip() except subprocess.CalledProcessError as e: - logger.debug('Error occurred while running shell command', exc_info=e) + if not silent_exc_info: + logger.debug('Error occurred while running shell command', exc_info=e) except subprocess.TimeoutExpired as e: logger.debug('Command timed out', exc_info=e) raise typer.Abort(f'Command "{command}" timed out') from e except Exception as e: - logger.debug('Unhandled exception occurred while running shell command', exc_info=e) + if not silent_exc_info: + logger.debug('Unhandled exception occurred while running shell command', exc_info=e) + raise click.ClickException(f'Unhandled exception: {e}') from e return None From c81407b9eae6a1a93ba7eda47d5c8e7b885f60ed Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Fri, 16 May 2025 13:36:41 +0200 Subject: [PATCH 170/257] CM-48457 - Fix Homebrew completions (#310) --- cycode/cli/app.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cycode/cli/app.py b/cycode/cli/app.py index 6fd4f70d..2ae004a6 100644 --- a/cycode/cli/app.py +++ b/cycode/cli/app.py @@ -4,6 +4,7 @@ import typer from typer import rich_utils from typer._completion_classes import completion_init +from typer._completion_shared import Shells from typer.completion import install_callback, show_callback from cycode import __version__ @@ -113,16 +114,17 @@ def app_callback( ), ] = False, __: Annotated[ - Optional[bool], + Shells, # the choice is required for Homebrew to be able to install the completion typer.Option( '--show-completion', callback=show_callback, is_eager=True, expose_value=False, - help='Show completion for the current shell, to copy it or customize the installation.', + show_default=False, + help='Show completion for the specified shell, to copy it or customize the installation.', rich_help_panel=_COMPLETION_RICH_HELP_PANEL, ), - ] = False, + ] = None, ) -> None: """[bold cyan]Cycode CLI - Command Line Interface for Cycode.[/]""" init_sentry() From c861b409ee7e0a1bad18cad78648d18f51f019ef Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 21 May 2025 16:48:41 +0200 Subject: [PATCH 171/257] CM-48095 - Add new files filter for SAST (#311) --- cycode/cli/apps/scan/code_scanner.py | 12 +- .../scan/pre_commit/pre_commit_command.py | 4 +- .../scan/repository/repository_command.py | 4 +- cycode/cli/apps/scan/scan_command.py | 9 +- cycode/cli/files_collector/excluder.py | 235 +++++++++--------- .../files_collector/models/in_memory_zip.py | 26 +- cycode/cli/files_collector/path_documents.py | 6 +- cycode/cyclient/models.py | 16 ++ cycode/cyclient/scan_client.py | 34 ++- tests/cli/commands/test_main_command.py | 5 +- .../cyclient/mocked_responses/scan_client.py | 19 ++ tests/test_code_scanner.py | 4 +- 12 files changed, 236 insertions(+), 138 deletions(-) diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index e7dff93f..21b5959e 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -15,7 +15,7 @@ from cycode.cli.console import console from cycode.cli.exceptions import custom_exceptions from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception -from cycode.cli.files_collector.excluder import exclude_irrelevant_documents_to_scan +from cycode.cli.files_collector.excluder import excluder from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip from cycode.cli.files_collector.path_documents import get_relevant_documents from cycode.cli.files_collector.repository_documents import ( @@ -56,8 +56,8 @@ def scan_sca_pre_commit(ctx: typer.Context, repo_path: str) -> None: progress_bar_section=ScanProgressBarSection.PREPARE_LOCAL_FILES, repo_path=repo_path, ) - git_head_documents = exclude_irrelevant_documents_to_scan(scan_type, git_head_documents) - pre_committed_documents = exclude_irrelevant_documents_to_scan(scan_type, pre_committed_documents) + git_head_documents = excluder.exclude_irrelevant_documents_to_scan(scan_type, git_head_documents) + pre_committed_documents = excluder.exclude_irrelevant_documents_to_scan(scan_type, pre_committed_documents) sca_code_scanner.perform_pre_hook_range_scan_actions(repo_path, git_head_documents, pre_committed_documents) scan_commit_range_documents( ctx, @@ -77,8 +77,8 @@ def scan_sca_commit_range(ctx: typer.Context, path: str, commit_range: str) -> N from_commit_documents, to_commit_documents = get_commit_range_modified_documents( progress_bar, ScanProgressBarSection.PREPARE_LOCAL_FILES, path, from_commit_rev, to_commit_rev ) - from_commit_documents = exclude_irrelevant_documents_to_scan(scan_type, from_commit_documents) - to_commit_documents = exclude_irrelevant_documents_to_scan(scan_type, to_commit_documents) + from_commit_documents = excluder.exclude_irrelevant_documents_to_scan(scan_type, from_commit_documents) + to_commit_documents = excluder.exclude_irrelevant_documents_to_scan(scan_type, to_commit_documents) sca_code_scanner.perform_pre_commit_range_scan_actions( path, from_commit_documents, from_commit_rev, to_commit_documents, to_commit_rev ) @@ -288,7 +288,7 @@ def scan_commit_range( {'path': path, 'commit_range': commit_range, 'commit_id': commit_id}, ) - documents_to_scan.extend(exclude_irrelevant_documents_to_scan(scan_type, commit_documents_to_scan)) + documents_to_scan.extend(excluder.exclude_irrelevant_documents_to_scan(scan_type, commit_documents_to_scan)) logger.debug('List of commit ids to scan, %s', {'commit_ids': commit_ids_to_scan}) logger.debug('Starting to scan commit range (it may take a few minutes)') diff --git a/cycode/cli/apps/scan/pre_commit/pre_commit_command.py b/cycode/cli/apps/scan/pre_commit/pre_commit_command.py index 40e6a8c1..9242b450 100644 --- a/cycode/cli/apps/scan/pre_commit/pre_commit_command.py +++ b/cycode/cli/apps/scan/pre_commit/pre_commit_command.py @@ -5,7 +5,7 @@ from cycode.cli import consts from cycode.cli.apps.scan.code_scanner import get_scan_parameters, scan_documents, scan_sca_pre_commit -from cycode.cli.files_collector.excluder import exclude_irrelevant_documents_to_scan +from cycode.cli.files_collector.excluder import excluder from cycode.cli.files_collector.repository_documents import ( get_diff_file_content, get_diff_file_path, @@ -45,5 +45,5 @@ def pre_commit_command( progress_bar.update(ScanProgressBarSection.PREPARE_LOCAL_FILES) documents_to_scan.append(Document(get_path_by_os(get_diff_file_path(file)), get_diff_file_content(file))) - documents_to_scan = exclude_irrelevant_documents_to_scan(scan_type, documents_to_scan) + documents_to_scan = excluder.exclude_irrelevant_documents_to_scan(scan_type, documents_to_scan) scan_documents(ctx, documents_to_scan, get_scan_parameters(ctx), is_git_diff=True) diff --git a/cycode/cli/apps/scan/repository/repository_command.py b/cycode/cli/apps/scan/repository/repository_command.py index c96ca577..e9a3f63d 100644 --- a/cycode/cli/apps/scan/repository/repository_command.py +++ b/cycode/cli/apps/scan/repository/repository_command.py @@ -7,7 +7,7 @@ from cycode.cli import consts from cycode.cli.apps.scan.code_scanner import get_scan_parameters, scan_documents from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception -from cycode.cli.files_collector.excluder import exclude_irrelevant_documents_to_scan +from cycode.cli.files_collector.excluder import excluder 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.logger import logger @@ -57,7 +57,7 @@ def repository_command( ) ) - documents_to_scan = exclude_irrelevant_documents_to_scan(scan_type, documents_to_scan) + documents_to_scan = excluder.exclude_irrelevant_documents_to_scan(scan_type, documents_to_scan) perform_pre_scan_documents_actions(ctx, scan_type, documents_to_scan) diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index a2ffb550..7c2de1a6 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -9,6 +9,7 @@ ISSUE_DETECTED_STATUS_CODE, NO_ISSUES_STATUS_CODE, ) +from cycode.cli.files_collector.excluder import excluder from cycode.cli.utils import scan_utils from cycode.cli.utils.get_api_client import get_scan_cycode_client from cycode.cli.utils.sentry import add_breadcrumb @@ -138,13 +139,19 @@ def scan_command( ctx.obj['show_secret'] = show_secret ctx.obj['soft_fail'] = soft_fail - ctx.obj['client'] = get_scan_cycode_client(ctx) ctx.obj['scan_type'] = scan_type ctx.obj['sync'] = sync ctx.obj['severity_threshold'] = severity_threshold ctx.obj['monitor'] = monitor ctx.obj['report'] = report + scan_client = get_scan_cycode_client(ctx) + ctx.obj['client'] = scan_client + + remote_scan_config = scan_client.get_scan_configuration_safe(scan_type) + if remote_scan_config: + excluder.apply_scan_config(str(scan_type), remote_scan_config) + if export_type and export_file: console_printer = ctx.obj['console_printer'] console_printer.enable_recording(export_type, export_file) diff --git a/cycode/cli/files_collector/excluder.py b/cycode/cli/files_collector/excluder.py index 9ef5e3d6..6abd8706 100644 --- a/cycode/cli/files_collector/excluder.py +++ b/cycode/cli/files_collector/excluder.py @@ -10,36 +10,12 @@ if TYPE_CHECKING: from cycode.cli.models import Document from cycode.cli.utils.progress_bar import BaseProgressBar, ProgressBarSection + from cycode.cyclient import models logger = get_logger('File Excluder') -def exclude_irrelevant_files( - progress_bar: 'BaseProgressBar', progress_bar_section: 'ProgressBarSection', scan_type: str, filenames: list[str] -) -> list[str]: - relevant_files = [] - for filename in filenames: - progress_bar.update(progress_bar_section) - if _is_relevant_file_to_scan(scan_type, filename): - relevant_files.append(filename) - - is_sub_path.cache_clear() # free up memory - - return relevant_files - - -def exclude_irrelevant_documents_to_scan(scan_type: str, documents_to_scan: list['Document']) -> list['Document']: - logger.debug('Excluding irrelevant documents to scan') - - relevant_documents = [] - for document in documents_to_scan: - if _is_relevant_document_to_scan(scan_type, document.path, document.content): - relevant_documents.append(document) - - return relevant_documents - - def _is_subpath_of_cycode_configuration_folder(filename: str) -> bool: return ( is_sub_path(configuration_manager.global_config_file_manager.get_config_directory_path(), filename) @@ -63,43 +39,6 @@ def _does_document_exceed_max_size_limit(content: str) -> bool: return get_content_size(content) > consts.FILE_MAX_SIZE_LIMIT_IN_BYTES -def _is_relevant_file_to_scan(scan_type: str, filename: str) -> bool: - if _is_subpath_of_cycode_configuration_folder(filename): - logger.debug( - 'The file is irrelevant because it is in the Cycode configuration directory, %s', - {'filename': filename, 'configuration_directory': consts.CYCODE_CONFIGURATION_DIRECTORY}, - ) - return False - - if _is_path_configured_in_exclusions(scan_type, filename): - logger.debug('The file is irrelevant because its path is in the ignore paths list, %s', {'filename': filename}) - return False - - if not _is_file_extension_supported(scan_type, filename): - logger.debug( - 'The file is irrelevant because its extension is not supported, %s', - {'scan_type': scan_type, 'filename': filename}, - ) - return False - - if is_binary_file(filename): - logger.debug('The file is irrelevant because it is a binary file, %s', {'filename': filename}) - return False - - if scan_type != consts.SCA_SCAN_TYPE and _does_file_exceed_max_size_limit(filename): - logger.debug( - 'The file is irrelevant because it has exceeded the maximum size limit, %s', - { - 'max_file_size': consts.FILE_MAX_SIZE_LIMIT_IN_BYTES, - 'file_size': get_file_size(filename), - 'filename': filename, - }, - ) - return False - - return not (scan_type == consts.SCA_SCAN_TYPE and not _is_file_relevant_for_sca_scan(filename)) - - def _is_file_relevant_for_sca_scan(filename: str) -> bool: if any(sca_excluded_path in filename for sca_excluded_path in consts.SCA_EXCLUDED_PATHS): logger.debug( @@ -110,52 +49,126 @@ def _is_file_relevant_for_sca_scan(filename: str) -> bool: return True -def _is_relevant_document_to_scan(scan_type: str, filename: str, content: str) -> bool: - if _is_subpath_of_cycode_configuration_folder(filename): - logger.debug( - 'The document is irrelevant because it is in the Cycode configuration directory, %s', - {'filename': filename, 'configuration_directory': consts.CYCODE_CONFIGURATION_DIRECTORY}, - ) - return False - - if _is_path_configured_in_exclusions(scan_type, filename): - logger.debug( - 'The document is irrelevant because its path is in the ignore paths list, %s', {'filename': filename} - ) - return False - - if not _is_file_extension_supported(scan_type, filename): - logger.debug( - 'The document is irrelevant because its extension is not supported, %s', - {'scan_type': scan_type, 'filename': filename}, - ) - return False - - if is_binary_content(content): - logger.debug('The document is irrelevant because it is a binary file, %s', {'filename': filename}) - return False - - if scan_type != consts.SCA_SCAN_TYPE and _does_document_exceed_max_size_limit(content): - logger.debug( - 'The document is irrelevant because it has exceeded the maximum size limit, %s', - { - 'max_document_size': consts.FILE_MAX_SIZE_LIMIT_IN_BYTES, - 'document_size': get_content_size(content), - 'filename': filename, - }, - ) - return False - - return True - - -def _is_file_extension_supported(scan_type: str, filename: str) -> bool: - filename = filename.lower() - - if scan_type == consts.IAC_SCAN_TYPE: - return filename.endswith(consts.IAC_SCAN_SUPPORTED_FILES) - - if scan_type == consts.SCA_SCAN_TYPE: - return filename.endswith(consts.SCA_CONFIGURATION_SCAN_SUPPORTED_FILES) - - return not filename.endswith(consts.SECRET_SCAN_FILE_EXTENSIONS_TO_IGNORE) +class Excluder: + def __init__(self) -> None: + self._scannable_extensions: dict[str, tuple[str, ...]] = { + consts.IAC_SCAN_TYPE: consts.IAC_SCAN_SUPPORTED_FILES, + consts.SCA_SCAN_TYPE: consts.SCA_CONFIGURATION_SCAN_SUPPORTED_FILES, + } + self._non_scannable_extensions: dict[str, tuple[str, ...]] = { + consts.SECRET_SCAN_TYPE: consts.SECRET_SCAN_FILE_EXTENSIONS_TO_IGNORE, + } + + def apply_scan_config(self, scan_type: str, scan_config: 'models.ScanConfiguration') -> None: + if scan_config.scannable_extensions: + self._scannable_extensions[scan_type] = tuple(scan_config.scannable_extensions) + + def _is_file_extension_supported(self, scan_type: str, filename: str) -> bool: + filename = filename.lower() + + scannable_extensions = self._scannable_extensions.get(scan_type) + if scannable_extensions: + return filename.endswith(scannable_extensions) + + non_scannable_extensions = self._non_scannable_extensions.get(scan_type) + if non_scannable_extensions: + return not filename.endswith(non_scannable_extensions) + + return True + + def _is_relevant_file_to_scan_common(self, scan_type: str, filename: str) -> bool: + if _is_subpath_of_cycode_configuration_folder(filename): + logger.debug( + 'The document is irrelevant because it is in the Cycode configuration directory, %s', + {'filename': filename, 'configuration_directory': consts.CYCODE_CONFIGURATION_DIRECTORY}, + ) + return False + + if _is_path_configured_in_exclusions(scan_type, filename): + logger.debug( + 'The document is irrelevant because its path is in the ignore paths list, %s', {'filename': filename} + ) + return False + + if not self._is_file_extension_supported(scan_type, filename): + logger.debug( + 'The document is irrelevant because its extension is not supported, %s', + {'scan_type': scan_type, 'filename': filename}, + ) + return False + + return True + + def _is_relevant_file_to_scan(self, scan_type: str, filename: str) -> bool: + if not self._is_relevant_file_to_scan_common(scan_type, filename): + return False + + if is_binary_file(filename): + logger.debug('The file is irrelevant because it is a binary file, %s', {'filename': filename}) + return False + + if scan_type != consts.SCA_SCAN_TYPE and _does_file_exceed_max_size_limit(filename): + logger.debug( + 'The file is irrelevant because it has exceeded the maximum size limit, %s', + { + 'max_file_size': consts.FILE_MAX_SIZE_LIMIT_IN_BYTES, + 'file_size': get_file_size(filename), + 'filename': filename, + }, + ) + return False + + return not (scan_type == consts.SCA_SCAN_TYPE and not _is_file_relevant_for_sca_scan(filename)) + + def _is_relevant_document_to_scan(self, scan_type: str, filename: str, content: str) -> bool: + if not self._is_relevant_file_to_scan_common(scan_type, filename): + return False + + if is_binary_content(content): + logger.debug('The document is irrelevant because it is a binary file, %s', {'filename': filename}) + return False + + if scan_type != consts.SCA_SCAN_TYPE and _does_document_exceed_max_size_limit(content): + logger.debug( + 'The document is irrelevant because it has exceeded the maximum size limit, %s', + { + 'max_document_size': consts.FILE_MAX_SIZE_LIMIT_IN_BYTES, + 'document_size': get_content_size(content), + 'filename': filename, + }, + ) + return False + + return True + + def exclude_irrelevant_files( + self, + progress_bar: 'BaseProgressBar', + progress_bar_section: 'ProgressBarSection', + scan_type: str, + filenames: list[str], + ) -> list[str]: + relevant_files = [] + for filename in filenames: + progress_bar.update(progress_bar_section) + if self._is_relevant_file_to_scan(scan_type, filename): + relevant_files.append(filename) + + is_sub_path.cache_clear() # free up memory + + return relevant_files + + def exclude_irrelevant_documents_to_scan( + self, scan_type: str, documents_to_scan: list['Document'] + ) -> list['Document']: + logger.debug('Excluding irrelevant documents to scan') + + relevant_documents = [] + for document in documents_to_scan: + if self._is_relevant_document_to_scan(scan_type, document.path, document.content): + relevant_documents.append(document) + + return relevant_documents + + +excluder = Excluder() diff --git a/cycode/cli/files_collector/models/in_memory_zip.py b/cycode/cli/files_collector/models/in_memory_zip.py index 8f58b12b..93ac4ac7 100644 --- a/cycode/cli/files_collector/models/in_memory_zip.py +++ b/cycode/cli/files_collector/models/in_memory_zip.py @@ -1,25 +1,28 @@ +from collections import defaultdict from io import BytesIO +from pathlib import Path from sys import getsizeof -from typing import TYPE_CHECKING, Optional +from typing import Optional from zipfile import ZIP_DEFLATED, ZipFile from cycode.cli.user_settings.configuration_manager import ConfigurationManager from cycode.cli.utils.path_utils import concat_unique_id -if TYPE_CHECKING: - from pathlib import Path - class InMemoryZip: def __init__(self) -> None: self.configuration_manager = ConfigurationManager() - # Create the in-memory file-like object self.in_memory_zip = BytesIO() - self.zip = ZipFile(self.in_memory_zip, 'a', ZIP_DEFLATED, False) + self.zip = ZipFile(self.in_memory_zip, mode='a', compression=ZIP_DEFLATED, allowZip64=False) + + self._files_count = 0 + self._extension_statistics = defaultdict(int) def append(self, filename: str, unique_id: Optional[str], content: str) -> None: - # Write the file to the in-memory zip + self._files_count += 1 + self._extension_statistics[Path(filename).suffix] += 1 + if unique_id: filename = concat_unique_id(filename, unique_id) @@ -28,7 +31,6 @@ def append(self, filename: str, unique_id: Optional[str], content: str) -> None: def close(self) -> None: self.zip.close() - # to bytes def read(self) -> bytes: self.in_memory_zip.seek(0) return self.in_memory_zip.read() @@ -40,3 +42,11 @@ def write_on_disk(self, path: 'Path') -> None: @property def size(self) -> int: return getsizeof(self.in_memory_zip) + + @property + def files_count(self) -> int: + return self._files_count + + @property + def extension_statistics(self) -> dict[str, int]: + return dict(self._extension_statistics) diff --git a/cycode/cli/files_collector/path_documents.py b/cycode/cli/files_collector/path_documents.py index e0f06312..556a8cf8 100644 --- a/cycode/cli/files_collector/path_documents.py +++ b/cycode/cli/files_collector/path_documents.py @@ -1,7 +1,7 @@ import os from typing import TYPE_CHECKING -from cycode.cli.files_collector.excluder import exclude_irrelevant_files +from cycode.cli.files_collector.excluder import excluder from cycode.cli.files_collector.iac.tf_content_generator import ( generate_tf_content_from_tfplan, generate_tfplan_document_name, @@ -54,7 +54,9 @@ def _get_relevant_files( progress_bar_section_len = len(all_files_to_scan) * 2 progress_bar.set_section_length(progress_bar_section, progress_bar_section_len) - relevant_files_to_scan = exclude_irrelevant_files(progress_bar, progress_bar_section, scan_type, all_files_to_scan) + relevant_files_to_scan = excluder.exclude_irrelevant_files( + progress_bar, progress_bar_section, scan_type, all_files_to_scan + ) # after finishing the first processing (excluding), # we must update the progress bar stage with respect of excluded files. diff --git a/cycode/cyclient/models.py b/cycode/cyclient/models.py index ed649644..fa952985 100644 --- a/cycode/cyclient/models.py +++ b/cycode/cyclient/models.py @@ -500,3 +500,19 @@ class Meta: @post_load def build_dto(self, data: dict[str, Any], **_) -> 'SupportedModulesPreferences': return SupportedModulesPreferences(**data) + + +@dataclass +class ScanConfiguration: + scannable_extensions: list[str] + + +class ScanConfigurationSchema(Schema): + class Meta: + unknown = EXCLUDE + + scannable_extensions = fields.List(fields.String(), allow_none=True) + + @post_load + def build_dto(self, data: dict[str, Any], **_) -> 'ScanConfiguration': + return ScanConfiguration(**data) diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index e0bf8131..b1c697c6 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -1,16 +1,17 @@ import json from copy import deepcopy -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING, Optional, Union from uuid import UUID from requests import Response from cycode.cli import consts from cycode.cli.config import configuration_manager -from cycode.cli.exceptions.custom_exceptions import CycodeError +from cycode.cli.exceptions.custom_exceptions import CycodeError, RequestHttpError from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip from cycode.cyclient import models from cycode.cyclient.cycode_client_base import CycodeClientBase +from cycode.cyclient.logger import logger if TYPE_CHECKING: from cycode.cyclient.scan_config_base import ScanConfigBase @@ -100,12 +101,19 @@ def zipped_file_scan_async( is_commit_range: bool = False, ) -> models.ScanInitializationResponse: files = {'file': ('multiple_files_scan.zip', zip_file.read())} + + compression_manifest = { + 'file_count_by_extension': zip_file.extension_statistics, + 'file_count': zip_file.files_count, + } + response = self.scan_cycode_client.post( url_path=self.get_zipped_file_scan_async_url_path(scan_type), data={ 'is_git_diff': is_git_diff, 'scan_parameters': json.dumps(scan_parameters), 'is_commit_range': is_commit_range, + 'compression_manifest': json.dumps(compression_manifest), }, files=files, ) @@ -245,3 +253,25 @@ def report_scan_status(self, scan_type: str, scan_id: str, scan_status: dict) -> @staticmethod def parse_scan_response(response: Response) -> models.ScanResult: return models.ScanResultSchema().load(response.json()) + + def get_scan_configuration_path(self, scan_type: str) -> str: + correct_scan_type = self.scan_config.get_async_scan_type(scan_type) + return f'{self.get_scan_service_url_path(scan_type)}/{correct_scan_type}/configuration' + + def get_scan_configuration(self, scan_type: str) -> models.ScanConfiguration: + response = self.scan_cycode_client.get( + url_path=self.get_scan_configuration_path(scan_type), + hide_response_content_log=self._hide_response_log, + ) + return models.ScanConfigurationSchema().load(response.json()) + + def get_scan_configuration_safe(self, scan_type: str) -> Optional['models.ScanConfiguration']: + try: + return self.get_scan_configuration(scan_type) + except RequestHttpError as e: + if e.status_code == 404: + logger.debug( + 'Remote scan configuration is not supported for this scan type: %s', {'scan_type': scan_type} + ) + else: + logger.debug('Failed to get remote scan configuration: %s', {'scan_type': scan_type}, exc_info=e) diff --git a/tests/cli/commands/test_main_command.py b/tests/cli/commands/test_main_command.py index db8fe86b..2436adee 100644 --- a/tests/cli/commands/test_main_command.py +++ b/tests/cli/commands/test_main_command.py @@ -8,10 +8,10 @@ from cycode.cli import consts from cycode.cli.app import app -from cycode.cli.cli_types import OutputTypeOption +from cycode.cli.cli_types import OutputTypeOption, ScanTypeOption from cycode.cli.utils.git_proxy import git_proxy from tests.conftest import CLI_ENV_VARS, TEST_FILES_PATH, ZIP_CONTENT_PATH -from tests.cyclient.mocked_responses.scan_client import mock_scan_async_responses +from tests.cyclient.mocked_responses.scan_client import mock_remote_config_responses, mock_scan_async_responses _PATH_TO_SCAN = TEST_FILES_PATH.joinpath('zip_content').absolute() @@ -71,6 +71,7 @@ def test_optional_git_with_path_scan(scan_client: 'ScanClient', api_token_respon @responses.activate def test_required_git_with_path_repository(scan_client: 'ScanClient', api_token_response: responses.Response) -> None: + mock_remote_config_responses(responses, ScanTypeOption.SECRET, scan_client) responses.add(api_token_response) # fake env without Git executable diff --git a/tests/cyclient/mocked_responses/scan_client.py b/tests/cyclient/mocked_responses/scan_client.py index 1726e74c..c37c1d8a 100644 --- a/tests/cyclient/mocked_responses/scan_client.py +++ b/tests/cyclient/mocked_responses/scan_client.py @@ -114,9 +114,28 @@ def get_detection_rules_response(url: str) -> responses.Response: return responses.Response(method=responses.GET, url=url, json=json_response, status=200) +def get_scan_configuration_url(scan_type: str, scan_client: ScanClient) -> str: + api_url = scan_client.scan_cycode_client.api_url + service_url = scan_client.get_scan_configuration_path(scan_type) + return f'{api_url}/{service_url}' + + +def get_scan_configuration_response(url: str) -> responses.Response: + json_response = { + 'scannable_extensions': None, + } + + return responses.Response(method=responses.GET, url=url, json=json_response, status=200) + + +def mock_remote_config_responses(responses_module: responses, scan_type: str, scan_client: ScanClient) -> None: + responses_module.add(get_scan_configuration_response(get_scan_configuration_url(scan_type, scan_client))) + + def mock_scan_async_responses( responses_module: responses, scan_type: str, scan_client: ScanClient, scan_id: UUID, zip_content_path: Path ) -> None: + mock_remote_config_responses(responses_module, scan_type, scan_client) responses_module.add( get_zipped_file_scan_async_response(get_zipped_file_scan_async_url(scan_type, scan_client), scan_id) ) diff --git a/tests/test_code_scanner.py b/tests/test_code_scanner.py index 9ef09123..01234d1d 100644 --- a/tests/test_code_scanner.py +++ b/tests/test_code_scanner.py @@ -9,7 +9,7 @@ _try_get_aggregation_report_url_if_needed, ) from cycode.cli.cli_types import ScanTypeOption -from cycode.cli.files_collector.excluder import _is_relevant_file_to_scan +from cycode.cli.files_collector.excluder import excluder from cycode.cyclient.scan_client import ScanClient from tests.conftest import TEST_FILES_PATH from tests.cyclient.mocked_responses.scan_client import ( @@ -20,7 +20,7 @@ def test_is_relevant_file_to_scan_sca() -> None: path = os.path.join(TEST_FILES_PATH, 'package.json') - assert _is_relevant_file_to_scan(consts.SCA_SCAN_TYPE, path) is True + assert excluder._is_relevant_file_to_scan(consts.SCA_SCAN_TYPE, path) is True @pytest.mark.parametrize('scan_type', list(ScanTypeOption)) From ac26e55ae263d0747a75c896de5bdfb5e060f6b6 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Fri, 23 May 2025 11:21:38 +0200 Subject: [PATCH 172/257] CM-46872 - Fix Maven dependencies restore for SCA (#312) --- .../sca/base_restore_dependencies.py | 9 ++-- .../sca/go/restore_go_dependencies.py | 3 -- .../sca/maven/restore_maven_dependencies.py | 44 ++++++++++--------- .../sca/ruby/restore_ruby_dependencies.py | 6 --- .../sca/sbt/restore_sbt_dependencies.py | 6 --- .../files_collector/sca/sca_code_scanner.py | 21 +++++---- 6 files changed, 39 insertions(+), 50 deletions(-) diff --git a/cycode/cli/files_collector/sca/base_restore_dependencies.py b/cycode/cli/files_collector/sca/base_restore_dependencies.py index ea8a0bb7..de409f05 100644 --- a/cycode/cli/files_collector/sca/base_restore_dependencies.py +++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py @@ -59,14 +59,13 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: manifest_file_path = self.get_manifest_file_path(document) restore_file_path = build_dep_tree_path(document.absolute_path, self.get_lock_file_name()) relative_restore_file_path = build_dep_tree_path(document.path, self.get_lock_file_name()) - working_directory_path = self.get_working_directory(document) if not self.verify_restore_file_already_exist(restore_file_path): output = execute_commands( - self.get_commands(manifest_file_path), - self.command_timeout, + commands=self.get_commands(manifest_file_path), + timeout=self.command_timeout, output_file_path=restore_file_path if self.create_output_file_manually else None, - working_directory=working_directory_path, + working_directory=self.get_working_directory(document), ) if output is None: # one of the commands failed return None @@ -75,7 +74,7 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: return Document(relative_restore_file_path, restore_file_content, self.is_git_diff) def get_working_directory(self, document: Document) -> Optional[str]: - return None + return os.path.dirname(document.absolute_path) @staticmethod def verify_restore_file_already_exist(restore_file_path: str) -> bool: diff --git a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py index 6eb48a76..156b0cc0 100644 --- a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py +++ b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py @@ -43,6 +43,3 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: def get_lock_file_name(self) -> str: return GO_RESTORE_FILE_NAME - - def get_working_directory(self, document: Document) -> Optional[str]: - return os.path.dirname(document.absolute_path) diff --git a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py index b9a2b1ed..589a0a2c 100644 --- a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py @@ -30,34 +30,36 @@ def get_lock_file_name(self) -> str: return join_paths('target', MAVEN_CYCLONE_DEP_TREE_FILE_NAME) def try_restore_dependencies(self, document: Document) -> Optional[Document]: - restore_dependencies_document = super().try_restore_dependencies(document) manifest_file_path = self.get_manifest_file_path(document) if document.content is None: - restore_dependencies_document = self.restore_from_secondary_command( - document, manifest_file_path, restore_dependencies_document - ) - else: - restore_dependencies_document.content = get_file_content( - join_paths(get_file_dir(manifest_file_path), self.get_lock_file_name()) - ) + return self.restore_from_secondary_command(document, manifest_file_path) + + restore_dependencies_document = super().try_restore_dependencies(document) + if restore_dependencies_document is None: + return None + + restore_dependencies_document.content = get_file_content( + join_paths(get_file_dir(manifest_file_path), self.get_lock_file_name()) + ) return restore_dependencies_document - def restore_from_secondary_command( - self, document: Document, manifest_file_path: str, restore_dependencies_document: Optional[Document] - ) -> Optional[Document]: - # TODO(MarshalX): does it even work? Ignored restore_dependencies_document arg - secondary_restore_command = create_secondary_restore_commands(manifest_file_path) - backup_restore_content = execute_commands(secondary_restore_command, self.command_timeout) - restore_dependencies_document = Document( - build_dep_tree_path(document.path, MAVEN_DEP_TREE_FILE_NAME), backup_restore_content, self.is_git_diff + def restore_from_secondary_command(self, document: Document, manifest_file_path: str) -> Optional[Document]: + restore_content = execute_commands( + commands=create_secondary_restore_commands(manifest_file_path), + timeout=self.command_timeout, + working_directory=self.get_working_directory(document), ) - restore_dependencies = None - if restore_dependencies_document.content is not None: - restore_dependencies = restore_dependencies_document - restore_dependencies.content = get_file_content(MAVEN_DEP_TREE_FILE_NAME) + if restore_content is None: + return None - return restore_dependencies + restore_file_path = build_dep_tree_path(document.absolute_path, MAVEN_DEP_TREE_FILE_NAME) + return Document( + path=build_dep_tree_path(document.path, MAVEN_DEP_TREE_FILE_NAME), + content=get_file_content(restore_file_path), + is_git_diff_format=self.is_git_diff, + absolute_path=restore_file_path, + ) def create_secondary_restore_commands(manifest_file_path: str) -> list[list[str]]: diff --git a/cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py b/cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py index 8c256f27..fb4a7771 100644 --- a/cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py +++ b/cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py @@ -1,6 +1,3 @@ -import os -from typing import Optional - from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies from cycode.cli.models import Document @@ -17,6 +14,3 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: def get_lock_file_name(self) -> str: return RUBY_LOCK_FILE_NAME - - def get_working_directory(self, document: Document) -> Optional[str]: - return os.path.dirname(document.absolute_path) diff --git a/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py b/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py index 26a88646..4f4bbd5a 100644 --- a/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py +++ b/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py @@ -1,6 +1,3 @@ -import os -from typing import Optional - from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies from cycode.cli.models import Document @@ -17,6 +14,3 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: def get_lock_file_name(self) -> str: return SBT_LOCK_FILE_NAME - - def get_working_directory(self, document: Document) -> Optional[str]: - return os.path.dirname(document.absolute_path) diff --git a/cycode/cli/files_collector/sca/sca_code_scanner.py b/cycode/cli/files_collector/sca/sca_code_scanner.py index b9988122..febd8858 100644 --- a/cycode/cli/files_collector/sca/sca_code_scanner.py +++ b/cycode/cli/files_collector/sca/sca_code_scanner.py @@ -92,17 +92,16 @@ def get_project_file_ecosystem(document: Document) -> Optional[str]: def try_restore_dependencies( ctx: typer.Context, - documents_to_add: dict[str, Document], restore_dependencies: 'BaseRestoreDependencies', document: Document, -) -> None: +) -> Optional[Document]: if not restore_dependencies.is_project(document): - return + return None restore_dependencies_document = restore_dependencies.restore(document) if restore_dependencies_document is None: logger.warning('Error occurred while trying to generate dependencies tree, %s', {'filename': document.path}) - return + return None if restore_dependencies_document.content is None: logger.warning('Error occurred while trying to generate dependencies tree, %s', {'filename': document.path}) @@ -114,10 +113,7 @@ def try_restore_dependencies( manifest_file_path = get_manifest_file_path(document, is_monitor_action, project_path) logger.debug('Succeeded to generate dependencies tree on path: %s', manifest_file_path) - if restore_dependencies_document.path in documents_to_add: - logger.debug('Duplicate document on restore for path: %s', restore_dependencies_document.path) - else: - documents_to_add[restore_dependencies_document.path] = restore_dependencies_document + return restore_dependencies_document def add_dependencies_tree_document( @@ -128,7 +124,14 @@ def add_dependencies_tree_document( for restore_dependencies in restore_dependencies_list: for document in documents_to_scan: - try_restore_dependencies(ctx, documents_to_add, restore_dependencies, document) + restore_dependencies_document = try_restore_dependencies(ctx, restore_dependencies, document) + if restore_dependencies_document is None: + continue + + if restore_dependencies_document.path in documents_to_add: + logger.debug('Duplicate document on restore for path: %s', restore_dependencies_document.path) + else: + documents_to_add[restore_dependencies_document.path] = restore_dependencies_document # mutate original list using slice assignment documents_to_scan[:] = list(documents_to_add.values()) From 9a9084314532f5b9781e9ee30fa982153873e56a Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 28 May 2025 09:48:37 +0200 Subject: [PATCH 173/257] CM-48734 - Update file filtering for all scan types (#313) --- cycode/cli/consts.py | 84 ++++++++++++++++++-------- cycode/cli/files_collector/excluder.py | 9 ++- 2 files changed, 67 insertions(+), 26 deletions(-) diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 286f1f95..c0ed33f0 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -14,38 +14,40 @@ SCA_SCAN_TYPE = 'sca' SAST_SCAN_TYPE = 'sast' -IAC_SCAN_SUPPORTED_FILES = ('.tf', '.tf.json', '.json', '.yaml', '.yml', 'dockerfile') +IAC_SCAN_SUPPORTED_FILE_EXTENSIONS = ('.tf', '.tf.json', '.json', '.yaml', '.yml', '.dockerfile', '.containerfile') +IAC_SCAN_SUPPORTED_FILE_PREFIXES = ('dockerfile', 'containerfile') SECRET_SCAN_FILE_EXTENSIONS_TO_IGNORE = ( - '.7z', + '.DS_Store', '.bmp', - '.bz2', - '.dmg', - '.exe', '.gif', - '.gz', '.ico', - '.jar', - '.jpg', - '.jpeg', - '.png', - '.rar', - '.realm', - '.s7z', - '.svg', - '.tar', '.tif', '.tiff', '.webp', - '.zi', + '.mp3', + '.mp4', + '.mkv', + '.avi', + '.mov', + '.mpg', + '.mpeg', + '.wav', + '.vob', + '.aac', + '.flac', + '.ogg', + '.mka', + '.wma', + '.wmv', + '.psd', + '.ai', + '.model', '.lock', '.css', - '.less', - '.dll', - '.enc', - '.deb', - '.obj', - '.model', + '.pdf', + '.odt', + '.iso', ) SCA_CONFIGURATION_SCAN_SUPPORTED_FILES = ( # keep in lowercase @@ -55,11 +57,18 @@ 'composer.lock', 'go.sum', 'go.mod', + 'go.mod.graph', 'gopkg.lock', 'pom.xml', + 'bom.json', + 'bcde.mvndeps', 'build.gradle', + '.gradle', 'gradle.lockfile', 'build.gradle.kts', + '.gradle.kts', + '.properties', + '.kt', # config KT files 'package.json', 'package-lock.json', 'yarn.lock', @@ -69,9 +78,10 @@ 'packages.lock.json', 'nuget.config', '.csproj', + '.vbproj', 'gemfile', 'gemfile.lock', - 'build.sbt', + '.sbt', 'build.scala', 'build.sbt.lock', 'pyproject.toml', @@ -84,14 +94,36 @@ 'mix.lock', 'package.swift', 'package.resolved', + 'pubspec.yaml', + 'pubspec.lock', + 'conanfile.py', + 'conanfile.txt', + 'maven_install.json', + 'conan.lock', ) -SCA_EXCLUDED_PATHS = ('node_modules',) +SCA_EXCLUDED_PATHS = ( + 'node_modules', + 'venv', + '.venv', + '__pycache__', + '.pytest_cache', + '.tox', + '.mvn', + '.gradle', + '.npm', + '.yarn', + '.bundle', + '.bloop', + '.build', + '.dart_tool', + '.pub', +) PROJECT_FILES_BY_ECOSYSTEM_MAP = { 'crates': ['Cargo.lock', 'Cargo.toml'], 'composer': ['composer.json', 'composer.lock'], - 'go': ['go.sum', 'go.mod', 'Gopkg.lock'], + 'go': ['go.sum', 'go.mod', 'go.mod.graph', 'Gopkg.lock'], 'maven_pom': ['pom.xml'], 'maven_gradle': ['build.gradle', 'build.gradle.kts', 'gradle.lockfile'], 'npm': ['package.json', 'package-lock.json', 'yarn.lock', 'npm-shrinkwrap.json', '.npmrc'], @@ -104,6 +136,8 @@ 'pypi_setup': ['setup.py'], 'hex': ['mix.exs', 'mix.lock'], 'swift_pm': ['Package.swift', 'Package.resolved'], + 'dart': ['pubspec.yaml', 'pubspec.lock'], + 'conan': ['conanfile.py', 'conanfile.txt', 'conan.lock'], } COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES = [SECRET_SCAN_TYPE, SCA_SCAN_TYPE] diff --git a/cycode/cli/files_collector/excluder.py b/cycode/cli/files_collector/excluder.py index 6abd8706..3a117b25 100644 --- a/cycode/cli/files_collector/excluder.py +++ b/cycode/cli/files_collector/excluder.py @@ -51,8 +51,11 @@ def _is_file_relevant_for_sca_scan(filename: str) -> bool: class Excluder: def __init__(self) -> None: + self._scannable_prefixes: dict[str, tuple[str, ...]] = { + consts.IAC_SCAN_TYPE: consts.IAC_SCAN_SUPPORTED_FILE_PREFIXES, + } self._scannable_extensions: dict[str, tuple[str, ...]] = { - consts.IAC_SCAN_TYPE: consts.IAC_SCAN_SUPPORTED_FILES, + consts.IAC_SCAN_TYPE: consts.IAC_SCAN_SUPPORTED_FILE_EXTENSIONS, consts.SCA_SCAN_TYPE: consts.SCA_CONFIGURATION_SCAN_SUPPORTED_FILES, } self._non_scannable_extensions: dict[str, tuple[str, ...]] = { @@ -74,6 +77,10 @@ def _is_file_extension_supported(self, scan_type: str, filename: str) -> bool: if non_scannable_extensions: return not filename.endswith(non_scannable_extensions) + scannable_prefixes = self._scannable_prefixes.get(scan_type) + if scannable_prefixes: + return filename.startswith(scannable_prefixes) + return True def _is_relevant_file_to_scan_common(self, scan_type: str, filename: str) -> bool: From 8ac74b3364e4edbd8e7c893a17afcb98b7c9d0cb Mon Sep 17 00:00:00 2001 From: juliebyrne-gh Date: Tue, 10 Jun 2025 10:55:18 -0400 Subject: [PATCH 174/257] Remove outdated RIG references with --monitor flag in README (#315) --- README.md | 41 ++++++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 13e23a6f..f5945134 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,8 @@ This guide walks you through both installation and usage. 6. [Ignoring via a config file](#ignoring-via-a-config-file) 5. [Report command](#report-command) 1. [Generating SBOM Report](#generating-sbom-report) -6. [Syntax Help](#syntax-help) +6. [Scan logs](#scan-logs) +7. [Syntax Help](#syntax-help) # Prerequisites @@ -291,20 +292,20 @@ The following are the options and commands available with the Cycode CLI applica The Cycode CLI application offers several types of scans so that you can choose the option that best fits your case. The following are the current options and commands available: -| Option | Description | -|------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `-t, --scan-type [secret\|iac\|sca\|sast]` | Specify the scan you wish to execute (`secret`/`iac`/`sca`/`sast`), the default is `secret`. | -| `--client-secret TEXT` | Specify a Cycode client secret for this specific scan execution. | -| `--client-id TEXT` | Specify a Cycode client ID for this specific scan execution. | -| `--show-secret BOOLEAN` | Show secrets in plain text. See [Show/Hide Secrets](#showhide-secrets) section for more details. | -| `--soft-fail BOOLEAN` | Run scan without failing, always return a non-error status code. See [Soft Fail](#soft-fail) section for more details. | -| `--severity-threshold [INFO\|LOW\|MEDIUM\|HIGH\|CRITICAL]` | Show only violations at the specified level or higher. | -| `--sca-scan` | Specify the SCA scan you wish to execute (`package-vulnerabilities`/`license-compliance`). The default is both. | -| `--monitor` | When specified, the scan results will be recorded in the knowledge graph. Please note that when working in `monitor` mode, the knowledge graph will not be updated as a result of SCM events (Push, Repo creation). (Supported for SCA scan type only). | -| `--cycode-report` | When specified, displays a link to the scan report in the Cycode platform in the console output. | -| `--no-restore` | When specified, Cycode will not run restore command. Will scan direct dependencies ONLY! | -| `--gradle-all-sub-projects` | When specified, Cycode will run gradle restore command for all sub projects. Should run from root project directory ONLY! | -| `--help` | Show options for given command. | +| Option | Description | +|------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------| +| `-t, --scan-type [secret\|iac\|sca\|sast]` | Specify the scan you wish to execute (`secret`/`iac`/`sca`/`sast`), the default is `secret`. | +| `--client-secret TEXT` | Specify a Cycode client secret for this specific scan execution. | +| `--client-id TEXT` | Specify a Cycode client ID for this specific scan execution. | +| `--show-secret BOOLEAN` | Show secrets in plain text. See [Show/Hide Secrets](#showhide-secrets) section for more details. | +| `--soft-fail BOOLEAN` | Run scan without failing, always return a non-error status code. See [Soft Fail](#soft-fail) section for more details. | +| `--severity-threshold [INFO\|LOW\|MEDIUM\|HIGH\|CRITICAL]` | Show only violations at the specified level or higher. | +| `--sca-scan` | Specify the SCA scan you wish to execute (`package-vulnerabilities`/`license-compliance`). The default is both. | +| `--monitor` | When specified, the scan results will be recorded in Cycode. | +| `--cycode-report` | When specified, displays a link to the scan report in the Cycode platform in the console output. | +| `--no-restore` | When specified, Cycode will not run restore command. Will scan direct dependencies ONLY! | +| `--gradle-all-sub-projects` | When specified, Cycode will run gradle restore command for all sub projects. Should run from root project directory ONLY! | +| `--help` | Show options for given command. | | Command | Description | |----------------------------------------|-----------------------------------------------------------------| @@ -328,16 +329,14 @@ The following command will scan the repository for policy violations that have s > [!NOTE] > This option is only available to SCA scans. -To push scan results tied to the [SCA policies](https://docs.cycode.com/docs/sca-policies) found in an SCA type scan to Cycode's knowledge graph, add the argument `--monitor` to the scan command. +To push scan results tied to the [SCA policies](https://docs.cycode.com/docs/sca-policies) found in an SCA type scan to Cycode, add the argument `--monitor` to the scan command. Consider the following example. The following command will scan the repository for SCA policy violations and push them to Cycode: `cycode scan -t sca --monitor repository ~/home/git/codebase` -When using this option, the scan results from this scan will appear in the knowledge graph, which can be found [here](https://app.cycode.com/query-builder). +When using this option, the scan results will appear in Cycode. -> [!WARNING] -> You must be an `owner` or an `admin` in Cycode to view the knowledge graph page. #### Cycode Report Option @@ -838,6 +837,10 @@ To create an SBOM report for a path:\ For example:\ `cycode report sbom --format spdx-2.3 --include-vulnerabilities --include-dev-dependencies path /path/to/local/project` +# Scan Logs + +All CLI scan are logged in Cycode. The logs can be found under Settings > CLI Logs. + # Syntax Help You may add the `--help` argument to any command at any time to see a help message that will display available options and their syntax. From 76b8bb39cf9a1cc21d7ad90985a37a5140d6c87e Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 10 Jun 2025 17:23:00 +0200 Subject: [PATCH 175/257] CM-48559 - Add commit history scan and pre-commit hook for SAST (#314) --- .pre-commit-hooks.yaml | 6 + README.md | 165 ++-- .../cli/apps/report/sbom/path/path_command.py | 4 +- cycode/cli/apps/scan/aggregation_report.py | 42 + cycode/cli/apps/scan/code_scanner.py | 837 +----------------- .../commit_history/commit_history_command.py | 4 +- cycode/cli/apps/scan/commit_range_scanner.py | 311 +++++++ cycode/cli/apps/scan/detection_excluder.py | 153 ++++ .../scan/pre_commit/pre_commit_command.py | 32 +- .../scan/pre_receive/pre_receive_command.py | 35 +- cycode/cli/apps/scan/remote_url_resolver.py | 115 +++ .../scan/repository/repository_command.py | 9 +- .../cli/apps/scan/scan_ci/scan_ci_command.py | 4 +- cycode/cli/apps/scan/scan_command.py | 2 +- cycode/cli/apps/scan/scan_parameters.py | 46 + cycode/cli/apps/scan/scan_result.py | 181 ++++ cycode/cli/consts.py | 4 +- .../files_collector/commit_range_documents.py | 289 ++++++ .../{excluder.py => file_excluder.py} | 0 cycode/cli/files_collector/path_documents.py | 2 +- .../files_collector/repository_documents.py | 134 +-- ..._code_scanner.py => sca_file_collector.py} | 124 ++- cycode/cli/files_collector/zip_documents.py | 6 +- .../cli/printers/tables/sca_table_printer.py | 2 +- cycode/cli/printers/tables/table_printer.py | 2 +- cycode/cli/printers/utils/__init__.py | 7 +- .../cli/printers/utils/code_snippet_syntax.py | 2 +- cycode/cli/utils/path_utils.py | 8 + cycode/cli/utils/scan_utils.py | 18 + cycode/cyclient/scan_client.py | 38 +- images/sca_report_url.png | Bin 205022 -> 243756 bytes tests/cli/commands/scan/test_code_scanner.py | 24 +- .../commands/scan/test_detection_excluder.py | 22 + ..._scanner.py => test_aggregation_report.py} | 12 +- 34 files changed, 1476 insertions(+), 1164 deletions(-) create mode 100644 cycode/cli/apps/scan/aggregation_report.py create mode 100644 cycode/cli/apps/scan/commit_range_scanner.py create mode 100644 cycode/cli/apps/scan/detection_excluder.py create mode 100644 cycode/cli/apps/scan/remote_url_resolver.py create mode 100644 cycode/cli/apps/scan/scan_parameters.py create mode 100644 cycode/cli/apps/scan/scan_result.py create mode 100644 cycode/cli/files_collector/commit_range_documents.py rename cycode/cli/files_collector/{excluder.py => file_excluder.py} (100%) rename cycode/cli/files_collector/sca/{sca_code_scanner.py => sca_file_collector.py} (70%) create mode 100644 tests/cli/commands/scan/test_detection_excluder.py rename tests/{test_code_scanner.py => test_aggregation_report.py} (81%) diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index ab69bf3f..c50e4d73 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -10,3 +10,9 @@ language_version: python3 entry: cycode args: [ '-o', 'text', '--no-progress-meter', 'scan', '-t', 'sca', 'pre-commit' ] +- id: cycode-sast + name: Cycode SAST pre-commit defender + language: python + language_version: python3 + entry: cycode + args: [ '-o', 'text', '--no-progress-meter', 'scan', '-t', 'sast', 'pre-commit' ] diff --git a/README.md b/README.md index f5945134..f3e57c10 100644 --- a/README.md +++ b/README.md @@ -86,9 +86,15 @@ To install the Cycode CLI application on your local machine, perform the followi brew install cycode ``` -3. Navigate to the top directory of the local repository you wish to scan. + - To install from [GitHub Releases](https://github.com/cycodehq/cycode-cli/releases) navigate and download executable for your operating system and architecture, then run the following command: -4. There are three methods to set the Cycode client ID and client secret: + ```bash + cd /path/to/downloaded/cycode-cli + chmod +x cycode + ./cycode + ``` + +3. Authenticate CLI. There are three methods to set the Cycode client ID and client secret: - [cycode auth](#using-the-auth-command) (**Recommended**) - [cycode configure](#using-the-configure-command) @@ -205,7 +211,7 @@ export CYCODE_CLIENT_SECRET={your Cycode Secret Key} Cycode’s pre-commit hook can be set up within your local repository so that the Cycode CLI application will identify any issues with your code automatically before you commit it to your codebase. > [!NOTE] -> pre-commit hook is only available to Secrets and SCA scans. +> pre-commit hook is not available for IaC scans. Perform the following steps to install the pre-commit hook: @@ -222,19 +228,19 @@ Perform the following steps to install the pre-commit hook: ```yaml repos: - repo: https://github.com/cycodehq/cycode-cli - rev: v3.0.0 + rev: v3.2.0 hooks: - id: cycode stages: - pre-commit ``` -4. Modify the created file for your specific needs. Use hook ID `cycode` to enable scan for Secrets. Use hook ID `cycode-sca` to enable SCA scan. If you want to enable both, use this configuration: +4. Modify the created file for your specific needs. Use hook ID `cycode` to enable scan for Secrets. Use hook ID `cycode-sca` to enable SCA scan. Use hook ID `cycode-sast` to enable SAST scan. If you want to enable all scanning types, use this configuration: ```yaml repos: - repo: https://github.com/cycodehq/cycode-cli - rev: v3.0.0 + rev: v3.2.0 hooks: - id: cycode stages: @@ -242,6 +248,9 @@ Perform the following steps to install the pre-commit hook: - id: cycode-sca stages: - pre-commit + - id: cycode-sast + stages: + - pre-commit ``` 5. Install Cycode’s hook: @@ -268,14 +277,17 @@ Perform the following steps to install the pre-commit hook: The following are the options and commands available with the Cycode CLI application: -| Option | Description | -|--------------------------------------|------------------------------------------------------------------------| -| `-v`, `--verbose` | Show detailed logs. | -| `--no-progress-meter` | Do not show the progress meter. | -| `--no-update-notifier` | Do not check CLI for updates. | -| `-o`, `--output [text\|json\|table]` | Specify the output (`text`/`json`/`table`). The default is `text`. | -| `--user-agent TEXT` | Characteristic JSON object that lets servers identify the application. | -| `--help` | Show options for given command. | +| Option | Description | +|-------------------------------------------------------------------|------------------------------------------------------------------------------------| +| `-v`, `--verbose` | Show detailed logs. | +| `--no-progress-meter` | Do not show the progress meter. | +| `--no-update-notifier` | Do not check CLI for updates. | +| `-o`, `--output [rich\|text\|json\|table]` | Specify the output type. The default is `rich`. | +| `--client-id TEXT` | Specify a Cycode client ID for this specific scan execution. | +| `--client-secret TEXT` | Specify a Cycode client secret for this specific scan execution. | +| `--install-completion` | Install completion for the current shell.. | +| `--show-completion [bash\|zsh\|fish\|powershell\|pwsh]` | Show completion for the specified shell, to copy it or customize the installation. | +| `-h`, `--help` | Show options for given command. | | Command | Description | |-------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------| @@ -295,8 +307,6 @@ The Cycode CLI application offers several types of scans so that you can choose | Option | Description | |------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------| | `-t, --scan-type [secret\|iac\|sca\|sast]` | Specify the scan you wish to execute (`secret`/`iac`/`sca`/`sast`), the default is `secret`. | -| `--client-secret TEXT` | Specify a Cycode client secret for this specific scan execution. | -| `--client-id TEXT` | Specify a Cycode client ID for this specific scan execution. | | `--show-secret BOOLEAN` | Show secrets in plain text. See [Show/Hide Secrets](#showhide-secrets) section for more details. | | `--soft-fail BOOLEAN` | Run scan without failing, always return a non-error status code. See [Soft Fail](#soft-fail) section for more details. | | `--severity-threshold [INFO\|LOW\|MEDIUM\|HIGH\|CRITICAL]` | Show only violations at the specified level or higher. | @@ -500,15 +510,7 @@ If no issues are found, the scan ends with the following success message: `Good job! No issues were found!!! 👏👏👏` -If an issue is found, a `Found issue of type:` message appears upon completion instead: - -```bash -⛔ Found issue of type: generic-password (rule ID: ce3a4de0-9dfc-448b-a004-c538cf8b4710) in file: config/my_config.py -Secret SHA: a44081db3296c84b82d12a35c446a3cba19411dddfa0380134c75f7b3973bff0 ⛔ -0 | @@ -0,0 +1 @@ -1 | +my_password = 'h3l***********350' -2 | \ No newline at end of file -``` +If an issue is found, a violation card appears upon completion instead. If an issue is found, review the file in question for the specific line highlighted by the result message. Implement any changes required to resolve the issue, then execute the scan again. @@ -524,15 +526,7 @@ In the following example, a Path Scan is executed against the `cli` subdirectory `cycode scan --show-secret path ./cli` -The result would then not be obfuscated: - -```bash -⛔ Found issue of type: generic-password (rule ID: ce3a4de0-9dfc-448b-a004-c538cf8b4710) in file: config/my_config.py -Secret SHA: a44081db3296c84b82d12a35c446a3cba19411dddfa0380134c75f7b3973bff0 ⛔ -0 | @@ -0,0 +1 @@ -1 | +my_password = 'h3110w0r1d!@#$350' -2 | \ No newline at end of file -``` +The result would then not be obfuscated. ### Soft Fail @@ -548,41 +542,92 @@ Scan results are assigned with a value of exit code `1` when issues are found in #### Secrets Result Example ```bash -⛔ Found issue of type: generic-password (rule ID: ce3a4de0-9dfc-448b-a004-c538cf8b4710) in file: config/my_config.py -Secret SHA: a44081db3296c84b82d12a35c446a3cba19411dddfa0380134c75f7b3973bff0 ⛔ -0 | @@ -0,0 +1 @@ -1 | +my_password = 'h3l***********350' -2 | \ No newline at end of file +╭─────────────────────────────────────────────────────────────── Hardcoded generic-password is used ───────────────────────────────────────────────────────────────╮ +│ Violation 12 of 12 │ +│ ╭─ 🔍 Details ───────────────────────────────────────╮ ╭─ 💻 Code Snippet ─────────────────────────────────────────────────────────────────────────────────────╮ │ +│ │ Severity 🟠 MEDIUM │ │ 34 }; │ │ +│ │ In file /Users/cycodemacuser/NodeGoat/test/s │ │ 35 │ │ +│ │ ecurity/profile-test.js │ │ 36 var sutUserName = "user1"; │ │ +│ │ Secret SHA b4ea3116d868b7c982ee6812cce61727856b │ │ ❱ 37 var sutUserPassword = "Us*****23"; │ │ +│ │ 802b3063cd5aebe7d796988552e0 │ │ 38 │ │ +│ │ Rule ID 68b6a876-4890-4e62-9531-0e687223579f │ │ 39 chrome.setDefaultService(service); │ │ +│ ╰────────────────────────────────────────────────────╯ │ 40 │ │ +│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ ╭─ 📝 Summary ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ +│ │ A generic secret or password is an authentication token used to access a computer or application and is assigned to a password variable. │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ``` #### IaC Result Example ```bash -⛔ Found issue of type: Resource should use non-default namespace (rule ID: bdaa88e2-5e7c-46ff-ac2a-29721418c59c) in file: ./k8s/k8s.yaml ⛔ - -7 | name: secrets-file -8 | namespace: default -9 | resourceVersion: "4228" +╭──────────── Enable Content Encoding through the attribute 'MinimumCompressionSize'. This value should be greater than -1 and smaller than 10485760. ─────────────╮ +│ Violation 45 of 110 │ +│ ╭─ 🔍 Details ───────────────────────────────────────╮ ╭─ 💻 Code Snippet ─────────────────────────────────────────────────────────────────────────────────────╮ │ +│ │ Severity 🟠 MEDIUM │ │ 20 BinaryMediaTypes: │ │ +│ │ In file ...ads-copy/iac/cft/api-gateway/ap │ │ 21 - !Ref binaryMediaType1 │ │ +│ │ i-gateway-rest-api/deploy.yml │ │ 22 - !Ref binaryMediaType2 │ │ +│ │ IaC Provider CloudFormation │ │ ❱ 23 MinimumCompressionSize: -1 │ │ +│ │ Rule ID 33c4b90c-3270-4337-a075-d3109c141b │ │ 24 EndpointConfiguration: │ │ +│ │ 53 │ │ 25 Types: │ │ +│ ╰────────────────────────────────────────────────────╯ │ 26 - EDGE │ │ +│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ ╭─ 📝 Summary ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ +│ │ This policy validates the proper configuration of content encoding in AWS API Gateway. Specifically, the policy checks for the attribute │ │ +│ │ 'minimum_compression_size' in API Gateway REST APIs. Correct configuration of this attribute is important for enabling content encoding of API responses for │ │ +│ │ improved API performance and reduced payload sizes. │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ``` #### SCA Result Example ```bash -⛔ Found issue of type: Security vulnerability in package 'pyyaml' referenced in project 'Users/myuser/my-test-repo': Improper Input Validation in PyYAML (rule ID: d003b23a-a2eb-42f3-83c9-7a84505603e5) in file: Users/myuser/my-test-repo/requirements.txt ⛔ - -1 | PyYAML~=5.3.1 -2 | vyper==0.3.1 -3 | cleo==1.0.0a5 +╭─────────────────────────────────────────────────────── [CVE-2019-10795] Prototype Pollution in undefsafe ────────────────────────────────────────────────────────╮ +│ Violation 172 of 195 │ +│ ╭─ 🔍 Details ───────────────────────────────────────╮ ╭─ 💻 Code Snippet ─────────────────────────────────────────────────────────────────────────────────────╮ │ +│ │ Severity 🟠 MEDIUM │ │ 26758 "integrity": "sha1-5z3T17DXxe2G+6xrCufYxqadUPo=", │ │ +│ │ In file /Users/cycodemacuser/Node │ │ 26759 "dev": true │ │ +│ │ Goat/package-lock.json │ │ 26760 }, │ │ +│ │ CVEs CVE-2019-10795 │ │ ❱ 26761 "undefsafe": { │ │ +│ │ Package undefsafe │ │ 26762 "version": "2.0.2", │ │ +│ │ Version 2.0.2 │ │ 26763 "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.2.tgz", │ │ +│ │ First patched version Not fixed │ │ 26764 "integrity": "sha1-Il9rngM3Zj4Njnz9aG/Cg2zKznY=", │ │ +│ │ Dependency path nodemon 1.19.1 -> │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ undefsafe 2.0.2 │ │ +│ │ Rule ID 9c6a8911-e071-4616-86db-4 │ │ +│ │ 943f2e1df81 │ │ +│ ╰────────────────────────────────────────────────────╯ │ +│ ╭─ 📝 Summary ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ +│ │ undefsafe before 2.0.3 is vulnerable to Prototype Pollution. The 'a' function could be tricked into adding or modifying properties of Object.prototype using │ │ +│ │ a __proto__ payload. │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ``` #### SAST Result Example ```bash -⛔ Found issue of type: Detected a request using 'http://'. This request will be unencrypted, and attackers could listen into traffic on the network and be able to obtain sensitive information. Use 'https://' instead. (rule ID: 3fbbd34b-b00d-4415-b9d9-f861c076b9f2) in file: ./requests.py ⛔ - -2 | -3 | res = requests.get('http://example.com', timeout=1) -4 | print(res.content) +╭───────────────────────────────────────────── [CWE-208: Observable Timing Discrepancy] Observable Timing Discrepancy ─────────────────────────────────────────────╮ +│ Violation 24 of 49 │ +│ ╭─ 🔍 Details ───────────────────────────────────────╮ ╭─ 💻 Code Snippet ─────────────────────────────────────────────────────────────────────────────────────╮ │ +│ │ Severity 🟠 MEDIUM │ │ 173 " including numbers, lowercase and uppercase letters."; │ │ +│ │ In file /Users/cycodemacuser/NodeGoat/app │ │ 174 return false; │ │ +│ │ /routes/session.js │ │ 175 } │ │ +│ │ CWE CWE-208 │ │ ❱ 176 if (password !== verify) { │ │ +│ │ Subcategory Security │ │ 177 errors.verifyError = "Password must match"; │ │ +│ │ Language js │ │ 178 return false; │ │ +│ │ Security Tool Bearer (Powered by Cycode) │ │ 179 } │ │ +│ │ Rule ID 19fbca07-a8e7-4fa6-92ac-a36d15509 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ fa9 │ │ +│ ╰────────────────────────────────────────────────────╯ │ +│ ╭─ 📝 Summary ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ +│ │ Observable Timing Discrepancy occurs when the time it takes for certain operations to complete can be measured and observed by attackers. This vulnerability │ │ +│ │ is particularly concerning when operations involve sensitive information, such as password checks or secret comparisons. If attackers can analyze how long │ │ +│ │ these operations take, they might be able to deduce confidential details, putting your data at risk. │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ``` ### Company’s Custom Remediation Guidelines @@ -609,18 +654,6 @@ The following are the options available for the `cycode ignore` command: | `-t, --scan-type [secret\|iac\|sca\|sast]` | Specify the scan you wish to execute (`secret`/`iac`/`sca`/`sast`). The default value is `secret`. | | `-g, --global` | Add an ignore rule and update it in the global `.cycode` config file. | -In the following example, a pre-commit scan runs and finds the following: - -```bash -⛔ Found issue of type: generic-password (rule ID: ce3a4de0-9dfc-448b-a004-c538cf8b4710) in file: config/my_config.py -Secret SHA: a44081db3296c84b82d12a35c446a3cba19411dddfa0380134c75f7b3973bff0 ⛔ -0 | @@ -0,0 +1 @@ -1 | +my_password = 'h3l***********350' -2 | \ No newline at end of file -``` - -If this is a value that is not a valid secret, then use the `cycode ignore` command to ignore the secret by its value, SHA value, specific path, or rule ID. If this is an IaC scan, then you can ignore that result by its path or rule ID. - ### Ignoring a Secret Value To ignore a specific secret value, you will need to use the `--by-value` flag. This will ignore the given secret value from all future scans. Use the following command to add a secret value to be ignored: diff --git a/cycode/cli/apps/report/sbom/path/path_command.py b/cycode/cli/apps/report/sbom/path/path_command.py index 9741aa73..9c839b08 100644 --- a/cycode/cli/apps/report/sbom/path/path_command.py +++ b/cycode/cli/apps/report/sbom/path/path_command.py @@ -8,7 +8,7 @@ from cycode.cli.apps.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.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.sca.sca_file_collector import add_sca_dependencies_tree_documents_if_needed from cycode.cli.files_collector.zip_documents import zip_documents from cycode.cli.utils.get_api_client import get_report_cycode_client from cycode.cli.utils.progress_bar import SbomReportProgressBarSection @@ -41,7 +41,7 @@ def path_command( ) # TODO(MarshalX): combine perform_pre_scan_documents_actions with get_relevant_document. # unhardcode usage of context in perform_pre_scan_documents_actions - perform_pre_scan_documents_actions(ctx, consts.SCA_SCAN_TYPE, documents) + add_sca_dependencies_tree_documents_if_needed(ctx, consts.SCA_SCAN_TYPE, documents) zipped_documents = zip_documents(consts.SCA_SCAN_TYPE, documents) report_execution = client.request_sbom_report_execution(report_parameters, zip_file=zipped_documents) diff --git a/cycode/cli/apps/scan/aggregation_report.py b/cycode/cli/apps/scan/aggregation_report.py new file mode 100644 index 00000000..45b891ed --- /dev/null +++ b/cycode/cli/apps/scan/aggregation_report.py @@ -0,0 +1,42 @@ +from typing import TYPE_CHECKING, Optional + +import typer + +from cycode.logger import get_logger + +if TYPE_CHECKING: + from cycode.cyclient.scan_client import ScanClient + +logger = get_logger('Aggregation Report URL') + + +def _set_aggregation_report_url(ctx: typer.Context, aggregation_report_url: Optional[str] = None) -> None: + ctx.obj['aggregation_report_url'] = aggregation_report_url + + +def try_get_aggregation_report_url_if_needed( + scan_parameters: dict, cycode_client: 'ScanClient', scan_type: str +) -> Optional[str]: + if not scan_parameters.get('report', False): + return None + + aggregation_id = scan_parameters.get('aggregation_id') + if aggregation_id is None: + return None + + try: + report_url_response = cycode_client.get_scan_aggregation_report_url(aggregation_id, scan_type) + return report_url_response.report_url + except Exception as e: + logger.debug('Failed to get aggregation report url: %s', str(e)) + + +def try_set_aggregation_report_url_if_needed( + ctx: typer.Context, scan_parameters: dict, cycode_client: 'ScanClient', scan_type: str +) -> None: + aggregation_report_url = try_get_aggregation_report_url_if_needed(scan_parameters, cycode_client, scan_type) + if aggregation_report_url: + _set_aggregation_report_url(ctx, aggregation_report_url) + logger.debug('Aggregation report URL set successfully', {'aggregation_report_url': aggregation_report_url}) + else: + logger.debug('No aggregation report URL found or report generation is disabled') diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index 21b5959e..19b43733 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -1,45 +1,33 @@ -import logging -import os -import sys import time from platform import platform from typing import TYPE_CHECKING, Callable, Optional -from uuid import UUID, uuid4 -import click import typer from cycode.cli import consts -from cycode.cli.cli_types import SeverityOption +from cycode.cli.apps.scan.aggregation_report import try_set_aggregation_report_url_if_needed +from cycode.cli.apps.scan.scan_parameters import get_scan_parameters +from cycode.cli.apps.scan.scan_result import ( + create_local_scan_result, + get_scan_result, + get_sync_scan_result, + print_local_scan_results, +) from cycode.cli.config import configuration_manager -from cycode.cli.console import console from cycode.cli.exceptions import custom_exceptions from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception -from cycode.cli.files_collector.excluder import excluder -from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip from cycode.cli.files_collector.path_documents import get_relevant_documents -from cycode.cli.files_collector.repository_documents import ( - get_commit_range_modified_documents, - get_diff_file_path, - get_pre_commit_modified_documents, - parse_commit_range, -) -from cycode.cli.files_collector.sca import sca_code_scanner -from cycode.cli.files_collector.sca.sca_code_scanner import perform_pre_scan_documents_actions +from cycode.cli.files_collector.sca.sca_file_collector import add_sca_dependencies_tree_documents_if_needed from cycode.cli.files_collector.zip_documents import zip_documents -from cycode.cli.models import CliError, Document, DocumentDetections, LocalScanResult -from cycode.cli.utils import scan_utils -from cycode.cli.utils.git_proxy import git_proxy -from cycode.cli.utils.path_utils import get_path_by_os +from cycode.cli.models import CliError, Document, LocalScanResult from cycode.cli.utils.progress_bar import ScanProgressBarSection from cycode.cli.utils.scan_batch import run_parallel_batched_scan -from cycode.cli.utils.scan_utils import set_issue_detected -from cycode.cli.utils.shell_executor import shell -from cycode.cyclient.models import Detection, DetectionSchema, DetectionsPerFile, ZippedFileScanResult -from cycode.logger import get_logger, set_logging_level +from cycode.cli.utils.scan_utils import generate_unique_scan_id, set_issue_detected_by_scan_results +from cycode.cyclient.models import ZippedFileScanResult +from cycode.logger import get_logger if TYPE_CHECKING: - from cycode.cyclient.models import ScanDetailsResponse + from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip from cycode.cyclient.scan_client import ScanClient start_scan_time = time.time() @@ -48,60 +36,18 @@ logger = get_logger('Code Scanner') -def scan_sca_pre_commit(ctx: typer.Context, repo_path: str) -> None: - scan_type = ctx.obj['scan_type'] - scan_parameters = get_scan_parameters(ctx) - git_head_documents, pre_committed_documents = get_pre_commit_modified_documents( - progress_bar=ctx.obj['progress_bar'], - progress_bar_section=ScanProgressBarSection.PREPARE_LOCAL_FILES, - repo_path=repo_path, - ) - git_head_documents = excluder.exclude_irrelevant_documents_to_scan(scan_type, git_head_documents) - pre_committed_documents = excluder.exclude_irrelevant_documents_to_scan(scan_type, pre_committed_documents) - sca_code_scanner.perform_pre_hook_range_scan_actions(repo_path, git_head_documents, pre_committed_documents) - scan_commit_range_documents( - ctx, - git_head_documents, - pre_committed_documents, - scan_parameters, - configuration_manager.get_sca_pre_commit_timeout_in_seconds(), - ) - - -def scan_sca_commit_range(ctx: typer.Context, path: str, commit_range: str) -> None: - scan_type = ctx.obj['scan_type'] - progress_bar = ctx.obj['progress_bar'] - - scan_parameters = get_scan_parameters(ctx, (path,)) - from_commit_rev, to_commit_rev = parse_commit_range(commit_range, path) - from_commit_documents, to_commit_documents = get_commit_range_modified_documents( - progress_bar, ScanProgressBarSection.PREPARE_LOCAL_FILES, path, from_commit_rev, to_commit_rev - ) - from_commit_documents = excluder.exclude_irrelevant_documents_to_scan(scan_type, from_commit_documents) - to_commit_documents = excluder.exclude_irrelevant_documents_to_scan(scan_type, to_commit_documents) - sca_code_scanner.perform_pre_commit_range_scan_actions( - path, from_commit_documents, from_commit_rev, to_commit_documents, to_commit_rev - ) - - scan_commit_range_documents(ctx, from_commit_documents, to_commit_documents, scan_parameters=scan_parameters) - - def scan_disk_files(ctx: typer.Context, paths: tuple[str, ...]) -> None: scan_type = ctx.obj['scan_type'] progress_bar = ctx.obj['progress_bar'] try: documents = get_relevant_documents(progress_bar, ScanProgressBarSection.PREPARE_LOCAL_FILES, scan_type, paths) - perform_pre_scan_documents_actions(ctx, scan_type, documents) + add_sca_dependencies_tree_documents_if_needed(ctx, scan_type, documents) scan_documents(ctx, documents, get_scan_parameters(ctx, paths)) except Exception as e: handle_scan_exception(ctx, e) -def set_issue_detected_by_scan_results(ctx: typer.Context, scan_results: list[LocalScanResult]) -> None: - set_issue_detected(ctx, any(scan_result.issue_detected for scan_result in scan_results)) - - def _should_use_sync_flow(command_scan_type: str, scan_type: str, sync_option: bool) -> bool: """Decide whether to use sync flow or async flow for the scan. @@ -175,7 +121,7 @@ def _scan_batch_thread_func(batch: list[Document]) -> tuple[str, CliError, Local local_scan_result = error = error_message = None detections_count = relevant_detections_count = zip_file_size = 0 - scan_id = str(_generate_unique_id()) + scan_id = str(generate_unique_scan_id()) scan_completed = False should_use_sync_flow = _should_use_sync_flow(command_scan_type, scan_type, sync_option) @@ -184,7 +130,7 @@ def _scan_batch_thread_func(batch: list[Document]) -> tuple[str, CliError, Local logger.debug('Preparing local files, %s', {'batch_files_count': len(batch)}) zipped_documents = zip_documents(scan_type, batch) zip_file_size = zipped_documents.size - scan_result = perform_scan( + scan_result = _perform_scan( cycode_client, zipped_documents, scan_type, @@ -219,7 +165,7 @@ def _scan_batch_thread_func(batch: list[Document]) -> tuple[str, CliError, Local 'zip_file_size': zip_file_size, }, ) - _report_scan_status( + report_scan_status( cycode_client, scan_type, scan_id, @@ -237,66 +183,6 @@ def _scan_batch_thread_func(batch: list[Document]) -> tuple[str, CliError, Local return _scan_batch_thread_func -def scan_commit_range( - ctx: typer.Context, path: str, commit_range: str, max_commits_count: Optional[int] = None -) -> None: - scan_type = ctx.obj['scan_type'] - - progress_bar = ctx.obj['progress_bar'] - progress_bar.start() - - if scan_type not in consts.COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES: - raise click.ClickException(f'Commit range scanning for {str.upper(scan_type)} is not supported') - - if scan_type == consts.SCA_SCAN_TYPE: - return scan_sca_commit_range(ctx, path, commit_range) - - documents_to_scan = [] - commit_ids_to_scan = [] - - repo = git_proxy.get_repo(path) - total_commits_count = int(repo.git.rev_list('--count', commit_range)) - logger.debug('Calculating diffs for %s commits in the commit range %s', total_commits_count, commit_range) - - progress_bar.set_section_length(ScanProgressBarSection.PREPARE_LOCAL_FILES, total_commits_count) - - for scanned_commits_count, commit in enumerate(repo.iter_commits(rev=commit_range)): - if _does_reach_to_max_commits_to_scan_limit(commit_ids_to_scan, max_commits_count): - logger.debug('Reached to max commits to scan count. Going to scan only %s last commits', max_commits_count) - progress_bar.update(ScanProgressBarSection.PREPARE_LOCAL_FILES, total_commits_count - scanned_commits_count) - break - - progress_bar.update(ScanProgressBarSection.PREPARE_LOCAL_FILES) - - commit_id = commit.hexsha - commit_ids_to_scan.append(commit_id) - parent = commit.parents[0] if commit.parents else git_proxy.get_null_tree() - diff_index = commit.diff(parent, create_patch=True, R=True) - commit_documents_to_scan = [] - for diff in diff_index: - commit_documents_to_scan.append( - Document( - path=get_path_by_os(get_diff_file_path(diff)), - content=diff.diff.decode('UTF-8', errors='replace'), - is_git_diff_format=True, - unique_id=commit_id, - ) - ) - - logger.debug( - 'Found all relevant files in commit %s', - {'path': path, 'commit_range': commit_range, 'commit_id': commit_id}, - ) - - documents_to_scan.extend(excluder.exclude_irrelevant_documents_to_scan(scan_type, commit_documents_to_scan)) - - logger.debug('List of commit ids to scan, %s', {'commit_ids': commit_ids_to_scan}) - logger.debug('Starting to scan commit range (it may take a few minutes)') - - scan_documents(ctx, documents_to_scan, get_scan_parameters(ctx, (path,)), is_git_diff=True, is_commit_range=True) - return None - - def scan_documents( ctx: typer.Context, documents_to_scan: list[Document], @@ -324,155 +210,17 @@ def scan_documents( scan_batch_thread_func, scan_type, documents_to_scan, progress_bar=progress_bar ) - aggregation_report_url = _try_get_aggregation_report_url_if_needed(scan_parameters, ctx.obj['client'], scan_type) - _set_aggregation_report_url(ctx, aggregation_report_url) + try_set_aggregation_report_url_if_needed(ctx, scan_parameters, ctx.obj['client'], scan_type) progress_bar.set_section_length(ScanProgressBarSection.GENERATE_REPORT, 1) progress_bar.update(ScanProgressBarSection.GENERATE_REPORT) progress_bar.stop() set_issue_detected_by_scan_results(ctx, local_scan_results) - print_results(ctx, local_scan_results, errors) - - -def scan_commit_range_documents( - ctx: typer.Context, - from_documents_to_scan: list[Document], - to_documents_to_scan: list[Document], - scan_parameters: Optional[dict] = None, - timeout: Optional[int] = None, -) -> None: - """In use by SCA only.""" - cycode_client = ctx.obj['client'] - scan_type = ctx.obj['scan_type'] - severity_threshold = ctx.obj['severity_threshold'] - scan_command_type = ctx.info_name - progress_bar = ctx.obj['progress_bar'] - - local_scan_result = error_message = None - scan_completed = False - scan_id = str(_generate_unique_id()) - from_commit_zipped_documents = InMemoryZip() - to_commit_zipped_documents = InMemoryZip() - - try: - progress_bar.set_section_length(ScanProgressBarSection.SCAN, 1) - - scan_result = init_default_scan_result(scan_id) - if should_scan_documents(from_documents_to_scan, to_documents_to_scan): - logger.debug('Preparing from-commit zip') - from_commit_zipped_documents = zip_documents(scan_type, from_documents_to_scan) - - logger.debug('Preparing to-commit zip') - to_commit_zipped_documents = zip_documents(scan_type, to_documents_to_scan) + print_local_scan_results(ctx, local_scan_results, errors) - scan_result = perform_commit_range_scan_async( - cycode_client, - from_commit_zipped_documents, - to_commit_zipped_documents, - scan_type, - scan_parameters, - timeout, - ) - - progress_bar.update(ScanProgressBarSection.SCAN) - progress_bar.set_section_length(ScanProgressBarSection.GENERATE_REPORT, 1) - - local_scan_result = create_local_scan_result( - scan_result, to_documents_to_scan, scan_command_type, scan_type, severity_threshold - ) - set_issue_detected_by_scan_results(ctx, [local_scan_result]) - - progress_bar.update(ScanProgressBarSection.GENERATE_REPORT) - progress_bar.stop() - # errors will be handled with try-except block; printing will not occur on errors - print_results(ctx, [local_scan_result]) - - scan_completed = True - except Exception as e: - handle_scan_exception(ctx, e) - error_message = str(e) - - zip_file_size = from_commit_zipped_documents.size + to_commit_zipped_documents.size - - detections_count = relevant_detections_count = 0 - if local_scan_result: - detections_count = local_scan_result.detections_count - relevant_detections_count = local_scan_result.relevant_detections_count - scan_id = local_scan_result.scan_id - - logger.debug( - 'Processing commit range scan results, %s', - { - 'all_violations_count': detections_count, - 'relevant_violations_count': relevant_detections_count, - 'scan_id': scan_id, - 'zip_file_size': zip_file_size, - }, - ) - _report_scan_status( - cycode_client, - scan_type, - scan_id, - scan_completed, - relevant_detections_count, - detections_count, - len(to_documents_to_scan), - zip_file_size, - scan_command_type, - error_message, - ) - - -def should_scan_documents(from_documents_to_scan: list[Document], to_documents_to_scan: list[Document]) -> bool: - return len(from_documents_to_scan) > 0 or len(to_documents_to_scan) > 0 - - -def create_local_scan_result( - scan_result: ZippedFileScanResult, - documents_to_scan: list[Document], - command_scan_type: str, - scan_type: str, - severity_threshold: str, -) -> LocalScanResult: - document_detections = get_document_detections(scan_result, documents_to_scan) - relevant_document_detections_list = exclude_irrelevant_document_detections( - document_detections, scan_type, command_scan_type, severity_threshold - ) - - detections_count = sum([len(document_detection.detections) for document_detection in document_detections]) - relevant_detections_count = sum( - [len(document_detections.detections) for document_detections in relevant_document_detections_list] - ) - - return LocalScanResult( - scan_id=scan_result.scan_id, - report_url=scan_result.report_url, - document_detections=relevant_document_detections_list, - issue_detected=len(relevant_document_detections_list) > 0, - detections_count=detections_count, - relevant_detections_count=relevant_detections_count, - ) - - -def perform_scan( - cycode_client: 'ScanClient', - zipped_documents: 'InMemoryZip', - scan_type: str, - is_git_diff: bool, - is_commit_range: bool, - scan_parameters: dict, - should_use_sync_flow: bool = False, -) -> ZippedFileScanResult: - if should_use_sync_flow: - # it does not support commit range scans; should_use_sync_flow handles it - return perform_scan_sync(cycode_client, zipped_documents, scan_type, scan_parameters, is_git_diff) - - return perform_scan_async(cycode_client, zipped_documents, scan_type, scan_parameters, is_commit_range) - - -def perform_scan_async( +def _perform_scan_async( cycode_client: 'ScanClient', zipped_documents: 'InMemoryZip', scan_type: str, @@ -492,38 +240,32 @@ def perform_scan_async( ) -def perform_scan_sync( +def _perform_scan_sync( cycode_client: 'ScanClient', zipped_documents: 'InMemoryZip', scan_type: str, scan_parameters: dict, is_git_diff: bool = False, -) -> ZippedFileScanResult: +) -> 'ZippedFileScanResult': scan_results = cycode_client.zipped_file_scan_sync(zipped_documents, scan_type, scan_parameters, is_git_diff) logger.debug('Sync scan request has been triggered successfully, %s', {'scan_id': scan_results.id}) - return ZippedFileScanResult( - did_detect=True, - detections_per_file=_map_detections_per_file_and_commit_id(scan_type, scan_results.detection_messages), - scan_id=scan_results.id, - ) + return get_sync_scan_result(scan_type, scan_results) -def perform_commit_range_scan_async( +def _perform_scan( cycode_client: 'ScanClient', - from_commit_zipped_documents: 'InMemoryZip', - to_commit_zipped_documents: 'InMemoryZip', + zipped_documents: 'InMemoryZip', scan_type: str, + is_git_diff: bool, + is_commit_range: bool, scan_parameters: dict, - timeout: Optional[int] = None, + should_use_sync_flow: bool = False, ) -> ZippedFileScanResult: - scan_async_result = cycode_client.multiple_zipped_file_scan_async( - from_commit_zipped_documents, to_commit_zipped_documents, scan_type, scan_parameters - ) + if should_use_sync_flow: + # it does not support commit range scans; should_use_sync_flow handles it + return _perform_scan_sync(cycode_client, zipped_documents, scan_type, scan_parameters, is_git_diff) - logger.debug( - 'Async commit range scan request has been triggered successfully, %s', {'scan_id': scan_async_result.scan_id} - ) - return poll_scan_results(cycode_client, scan_async_result.scan_id, scan_type, scan_parameters, timeout) + return _perform_scan_async(cycode_client, zipped_documents, scan_type, scan_parameters, is_commit_range) def poll_scan_results( @@ -532,7 +274,7 @@ def poll_scan_results( scan_type: str, scan_parameters: dict, polling_timeout: Optional[int] = None, -) -> ZippedFileScanResult: +) -> 'ZippedFileScanResult': if polling_timeout is None: polling_timeout = configuration_manager.get_scan_polling_timeout_in_seconds() @@ -544,10 +286,13 @@ def poll_scan_results( if scan_details.scan_update_at is not None and scan_details.scan_update_at != last_scan_update_at: last_scan_update_at = scan_details.scan_update_at - print_debug_scan_details(scan_details) + logger.debug('Scan update, %s', {'scan_id': scan_details.id, 'scan_status': scan_details.scan_status}) + + if scan_details.message: + logger.debug('Scan message: %s', scan_details.message) if scan_details.scan_status == consts.SCAN_STATUS_COMPLETED: - return _get_scan_result(cycode_client, scan_type, scan_id, scan_details, scan_parameters) + return get_scan_result(cycode_client, scan_type, scan_id, scan_details, scan_parameters) if scan_details.scan_status == consts.SCAN_STATUS_ERROR: raise custom_exceptions.ScanAsyncError( @@ -559,350 +304,7 @@ def poll_scan_results( raise custom_exceptions.ScanAsyncError(f'Failed to complete scan after {polling_timeout} seconds') -def print_debug_scan_details(scan_details_response: 'ScanDetailsResponse') -> None: - logger.debug( - 'Scan update, %s', {'scan_id': scan_details_response.id, 'scan_status': scan_details_response.scan_status} - ) - - if scan_details_response.message: - logger.debug('Scan message: %s', scan_details_response.message) - - -def print_results( - ctx: typer.Context, local_scan_results: list[LocalScanResult], errors: Optional[dict[str, 'CliError']] = None -) -> None: - printer = ctx.obj.get('console_printer') - printer.update_ctx(ctx) - printer.print_scan_results(local_scan_results, errors) - - -def get_document_detections( - scan_result: ZippedFileScanResult, documents_to_scan: list[Document] -) -> list[DocumentDetections]: - logger.debug('Getting document detections') - - document_detections = [] - for detections_per_file in scan_result.detections_per_file: - file_name = get_path_by_os(detections_per_file.file_name) - commit_id = detections_per_file.commit_id - - logger.debug( - 'Going to find the document of the violated file, %s', {'file_name': file_name, 'commit_id': commit_id} - ) - - document = _get_document_by_file_name(documents_to_scan, file_name, commit_id) - document_detections.append(DocumentDetections(document=document, detections=detections_per_file.detections)) - - return document_detections - - -def exclude_irrelevant_document_detections( - document_detections_list: list[DocumentDetections], - scan_type: str, - command_scan_type: str, - severity_threshold: str, -) -> list[DocumentDetections]: - relevant_document_detections_list = [] - for document_detections in document_detections_list: - relevant_detections = exclude_irrelevant_detections( - document_detections.detections, scan_type, command_scan_type, severity_threshold - ) - if relevant_detections: - relevant_document_detections_list.append( - DocumentDetections(document=document_detections.document, detections=relevant_detections) - ) - - return relevant_document_detections_list - - -def parse_pre_receive_input() -> str: - """Parse input to pushed branch update details. - - Example input: - old_value new_value refname - ----------------------------------------------- - 0000000000000000000000000000000000000000 9cf90954ef26e7c58284f8ebf7dcd0fcf711152a refs/heads/main - 973a96d3e925b65941f7c47fa16129f1577d499f 0000000000000000000000000000000000000000 refs/heads/feature-branch - 59564ef68745bca38c42fc57a7822efd519a6bd9 3378e52dcfa47fb11ce3a4a520bea5f85d5d0bf3 refs/heads/develop - - :return: First branch update details (input's first line) - """ - # FIXME(MarshalX): this blocks main thread forever if called outside of pre-receive hook - pre_receive_input = sys.stdin.read().strip() - if not pre_receive_input: - raise ValueError( - 'Pre receive input was not found. Make sure that you are using this command only in pre-receive hook' - ) - - # each line represents a branch update request, handle the first one only - # TODO(MichalBor): support case of multiple update branch requests - return pre_receive_input.splitlines()[0] - - -def _get_default_scan_parameters(ctx: typer.Context) -> dict: - return { - 'monitor': ctx.obj.get('monitor'), - 'report': ctx.obj.get('report'), - 'package_vulnerabilities': ctx.obj.get('package-vulnerabilities'), - 'license_compliance': ctx.obj.get('license-compliance'), - 'command_type': ctx.info_name.replace('-', '_'), # save backward compatibility - 'aggregation_id': str(_generate_unique_id()), - } - - -def get_scan_parameters(ctx: typer.Context, paths: Optional[tuple[str, ...]] = None) -> dict: - scan_parameters = _get_default_scan_parameters(ctx) - - if not paths: - return scan_parameters - - scan_parameters['paths'] = paths - - if len(paths) != 1: - logger.debug('Multiple paths provided, going to ignore remote url') - return scan_parameters - - if not os.path.isdir(paths[0]): - logger.debug('Path is not a directory, going to ignore remote url') - return scan_parameters - - remote_url = try_get_git_remote_url(paths[0]) - if not remote_url: - remote_url = try_to_get_plastic_remote_url(paths[0]) - - if remote_url: - # TODO(MarshalX): remove hardcode in context - ctx.obj['remote_url'] = remote_url - scan_parameters['remote_url'] = remote_url - - return scan_parameters - - -def try_get_git_remote_url(path: str) -> Optional[str]: - try: - remote_url = git_proxy.get_repo(path).remotes[0].config_reader.get('url') - logger.debug('Found Git remote URL, %s', {'remote_url': remote_url, 'path': path}) - return remote_url - except Exception: - logger.debug('Failed to get Git remote URL. Probably not a Git repository') - return None - - -def _get_plastic_repository_name(path: str) -> Optional[str]: - """Get the name of the Plastic repository from the current working directory. - - The command to execute is: - cm status --header --machinereadable --fieldseparator=":::" - - Example of status header in machine-readable format: - STATUS:::0:::Project/RepoName:::OrgName@ServerInfo - """ - try: - command = [ - 'cm', - 'status', - '--header', - '--machinereadable', - f'--fieldseparator={consts.PLASTIC_VCS_DATA_SEPARATOR}', - ] - - status = shell( - command=command, timeout=consts.PLASTIC_VSC_CLI_TIMEOUT, working_directory=path, silent_exc_info=True - ) - if not status: - logger.debug('Failed to get Plastic repository name (command failed)') - return None - - status_parts = status.split(consts.PLASTIC_VCS_DATA_SEPARATOR) - if len(status_parts) < 2: - logger.debug('Failed to parse Plastic repository name (command returned unexpected format)') - return None - - return status_parts[2].strip() - except Exception: - logger.debug('Failed to get Plastic repository name. Probably not a Plastic repository') - return None - - -def _get_plastic_repository_list(working_dir: Optional[str] = None) -> dict[str, str]: - """Get the list of Plastic repositories and their GUIDs. - - The command to execute is: - cm repo list --format="{repname}:::{repguid}" - - Example line with data: - Project/RepoName:::tapo1zqt-wn99-4752-h61m-7d9k79d40r4v - - Each line represents an individual repository. - """ - repo_name_to_guid = {} - - try: - command = ['cm', 'repo', 'ls', f'--format={{repname}}{consts.PLASTIC_VCS_DATA_SEPARATOR}{{repguid}}'] - - status = shell( - command=command, timeout=consts.PLASTIC_VSC_CLI_TIMEOUT, working_directory=working_dir, silent_exc_info=True - ) - if not status: - logger.debug('Failed to get Plastic repository list (command failed)') - return repo_name_to_guid - - status_lines = status.splitlines() - for line in status_lines: - data_parts = line.split(consts.PLASTIC_VCS_DATA_SEPARATOR) - if len(data_parts) < 2: - logger.debug('Failed to parse Plastic repository list line (unexpected format), %s', {'line': line}) - continue - - repo_name, repo_guid = data_parts - repo_name_to_guid[repo_name.strip()] = repo_guid.strip() - - return repo_name_to_guid - except Exception as e: - logger.debug('Failed to get Plastic repository list', exc_info=e) - return repo_name_to_guid - - -def try_to_get_plastic_remote_url(path: str) -> Optional[str]: - repository_name = _get_plastic_repository_name(path) - if not repository_name: - return None - - repository_map = _get_plastic_repository_list(path) - if repository_name not in repository_map: - logger.debug('Failed to get Plastic repository GUID (repository not found in the list)') - return None - - repository_guid = repository_map[repository_name] - return f'{consts.PLASTIC_VCS_REMOTE_URI_PREFIX}{repository_guid}' - - -def exclude_irrelevant_detections( - detections: list[Detection], scan_type: str, command_scan_type: str, severity_threshold: str -) -> list[Detection]: - relevant_detections = _exclude_detections_by_exclusions_configuration(detections, scan_type) - relevant_detections = _exclude_detections_by_scan_type(relevant_detections, scan_type, command_scan_type) - return _exclude_detections_by_severity(relevant_detections, severity_threshold) - - -def _exclude_detections_by_severity(detections: list[Detection], severity_threshold: str) -> list[Detection]: - relevant_detections = [] - for detection in detections: - severity = detection.severity - - if _does_severity_match_severity_threshold(severity, severity_threshold): - relevant_detections.append(detection) - else: - logger.debug( - 'Going to ignore violations because they are below the severity threshold, %s', - {'severity': severity, 'severity_threshold': severity_threshold}, - ) - - return relevant_detections - - -def _exclude_detections_by_scan_type( - detections: list[Detection], scan_type: str, command_scan_type: str -) -> list[Detection]: - if command_scan_type == consts.PRE_COMMIT_COMMAND_SCAN_TYPE: - return exclude_detections_in_deleted_lines(detections) - - exclude_in_deleted_lines = configuration_manager.get_should_exclude_detections_in_deleted_lines(command_scan_type) - if ( - command_scan_type in consts.COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES - and scan_type == consts.SECRET_SCAN_TYPE - and exclude_in_deleted_lines - ): - return exclude_detections_in_deleted_lines(detections) - - return detections - - -def exclude_detections_in_deleted_lines(detections: list[Detection]) -> list[Detection]: - return [detection for detection in detections if detection.detection_details.get('line_type') != 'Removed'] - - -def _exclude_detections_by_exclusions_configuration(detections: list[Detection], scan_type: str) -> list[Detection]: - exclusions = configuration_manager.get_exclusions_by_scan_type(scan_type) - return [detection for detection in detections if not _should_exclude_detection(detection, exclusions)] - - -def _should_exclude_detection(detection: Detection, exclusions: dict) -> bool: - # FIXME(MarshalX): what the difference between by_value and by_sha? - exclusions_by_value = exclusions.get(consts.EXCLUSIONS_BY_VALUE_SECTION_NAME, []) - if _is_detection_sha_configured_in_exclusions(detection, exclusions_by_value): - logger.debug( - 'Ignoring violation because its value is on the ignore list, %s', - {'value_sha': detection.detection_details.get('sha512')}, - ) - return True - - exclusions_by_sha = exclusions.get(consts.EXCLUSIONS_BY_SHA_SECTION_NAME, []) - if _is_detection_sha_configured_in_exclusions(detection, exclusions_by_sha): - logger.debug( - 'Ignoring violation because its SHA value is on the ignore list, %s', - {'sha': detection.detection_details.get('sha512')}, - ) - return True - - exclusions_by_rule = exclusions.get(consts.EXCLUSIONS_BY_RULE_SECTION_NAME, []) - detection_rule_id = detection.detection_rule_id - if detection_rule_id in exclusions_by_rule: - logger.debug( - 'Ignoring violation because its Detection Rule ID is on the ignore list, %s', - {'detection_rule_id': detection_rule_id}, - ) - return True - - exclusions_by_package = exclusions.get(consts.EXCLUSIONS_BY_PACKAGE_SECTION_NAME, []) - package = _get_package_name(detection) - if package and package in exclusions_by_package: - logger.debug('Ignoring violation because its package@version is on the ignore list, %s', {'package': package}) - return True - - exclusions_by_cve = exclusions.get(consts.EXCLUSIONS_BY_CVE_SECTION_NAME, []) - cve = _get_cve_identifier(detection) - if cve and cve in exclusions_by_cve: - logger.debug('Ignoring violation because its CVE is on the ignore list, %s', {'cve': cve}) - return True - - return False - - -def _is_detection_sha_configured_in_exclusions(detection: Detection, exclusions: list[str]) -> bool: - detection_sha = detection.detection_details.get('sha512') - return detection_sha in exclusions - - -def _get_package_name(detection: Detection) -> Optional[str]: - package_name = detection.detection_details.get('vulnerable_component') - package_version = detection.detection_details.get('vulnerable_component_version') - - if package_name is None: - package_name = detection.detection_details.get('package_name') - package_version = detection.detection_details.get('package_version') - - if package_name and package_version: - return f'{package_name}@{package_version}' - - return None - - -def _get_cve_identifier(detection: Detection) -> Optional[str]: - return detection.detection_details.get('alert', {}).get('cve_identifier') - - -def _get_document_by_file_name( - documents: list[Document], file_name: str, unique_id: Optional[str] = None -) -> Optional[Document]: - for document in documents: - if _normalize_file_path(document.path) == _normalize_file_path(file_name) and document.unique_id == unique_id: - return document - - return None - - -def _report_scan_status( +def report_scan_status( cycode_client: 'ScanClient', scan_type: str, scan_id: str, @@ -932,162 +334,3 @@ def _report_scan_status( cycode_client.report_scan_status(scan_type, scan_id, scan_status) except Exception as e: logger.debug('Failed to report scan status', exc_info=e) - - -def _generate_unique_id() -> UUID: - if 'PYTEST_TEST_UNIQUE_ID' in os.environ: - return UUID(os.environ['PYTEST_TEST_UNIQUE_ID']) - - return uuid4() - - -def _does_severity_match_severity_threshold(severity: str, severity_threshold: str) -> bool: - detection_severity_value = SeverityOption.get_member_weight(severity) - severity_threshold_value = SeverityOption.get_member_weight(severity_threshold) - if detection_severity_value < 0 or severity_threshold_value < 0: - return True - - return detection_severity_value >= severity_threshold_value - - -def _get_scan_result( - cycode_client: 'ScanClient', - scan_type: str, - scan_id: str, - scan_details: 'ScanDetailsResponse', - scan_parameters: dict, -) -> ZippedFileScanResult: - if not scan_details.detections_count: - return init_default_scan_result(scan_id) - - scan_raw_detections = cycode_client.get_scan_raw_detections(scan_id) - - return ZippedFileScanResult( - did_detect=True, - detections_per_file=_map_detections_per_file_and_commit_id(scan_type, scan_raw_detections), - scan_id=scan_id, - report_url=_try_get_aggregation_report_url_if_needed(scan_parameters, cycode_client, scan_type), - ) - - -def init_default_scan_result(scan_id: str) -> ZippedFileScanResult: - return ZippedFileScanResult( - did_detect=False, - detections_per_file=[], - scan_id=scan_id, - ) - - -def _set_aggregation_report_url(ctx: typer.Context, aggregation_report_url: Optional[str] = None) -> None: - ctx.obj['aggregation_report_url'] = aggregation_report_url - - -def _try_get_aggregation_report_url_if_needed( - scan_parameters: dict, cycode_client: 'ScanClient', scan_type: str -) -> Optional[str]: - if not scan_parameters.get('report', False): - return None - - aggregation_id = scan_parameters.get('aggregation_id') - if aggregation_id is None: - return None - - try: - report_url_response = cycode_client.get_scan_aggregation_report_url(aggregation_id, scan_type) - return report_url_response.report_url - except Exception as e: - logger.debug('Failed to get aggregation report url: %s', str(e)) - - -def _map_detections_per_file_and_commit_id(scan_type: str, raw_detections: list[dict]) -> list[DetectionsPerFile]: - """Convert a list of detections (async flow) to list of DetectionsPerFile objects (sync flow). - - Args: - scan_type: Type of the scan. - raw_detections: List of detections as is returned from the server. - - Note: - This method fakes server response structure - to be able to use the same logic for both async and sync scans. - - Note: - Aggregation is performed by file name and commit ID (if available) - - """ - detections_per_files = {} - for raw_detection in raw_detections: - try: - # FIXME(MarshalX): investigate this field mapping - raw_detection['message'] = raw_detection['correlation_message'] - - file_name = _get_file_name_from_detection(scan_type, raw_detection) - detection: Detection = DetectionSchema().load(raw_detection) - commit_id: Optional[str] = detection.detection_details.get('commit_id') # could be None - group_by_key = (file_name, commit_id) - - if group_by_key in detections_per_files: - detections_per_files[group_by_key].append(detection) - else: - detections_per_files[group_by_key] = [detection] - except Exception as e: - logger.debug('Failed to parse detection', exc_info=e) - continue - - return [ - DetectionsPerFile(file_name=file_name, detections=file_detections, commit_id=commit_id) - for (file_name, commit_id), file_detections in detections_per_files.items() - ] - - -def _get_file_name_from_detection(scan_type: str, raw_detection: dict) -> str: - if scan_type == consts.SAST_SCAN_TYPE: - return raw_detection['detection_details']['file_path'] - if scan_type == consts.SECRET_SCAN_TYPE: - return _get_secret_file_name_from_detection(raw_detection) - - return raw_detection['detection_details']['file_name'] - - -def _get_secret_file_name_from_detection(raw_detection: dict) -> str: - file_path: str = raw_detection['detection_details']['file_path'] - file_name: str = raw_detection['detection_details']['file_name'] - return os.path.join(file_path, file_name) - - -def _does_reach_to_max_commits_to_scan_limit(commit_ids: list[str], max_commits_count: Optional[int]) -> bool: - if max_commits_count is None: - return False - - return len(commit_ids) >= max_commits_count - - -def _normalize_file_path(path: str) -> str: - if path.startswith('/'): - return path[1:] - if path.startswith('./'): - return path[2:] - return path - - -def perform_post_pre_receive_scan_actions(ctx: typer.Context) -> None: - if scan_utils.is_scan_failed(ctx): - console.print(consts.PRE_RECEIVE_REMEDIATION_MESSAGE) - - -def enable_verbose_mode(ctx: typer.Context) -> None: - ctx.obj['verbose'] = True - set_logging_level(logging.DEBUG) - - -def is_verbose_mode_requested_in_pre_receive_scan() -> bool: - return does_git_push_option_have_value(consts.VERBOSE_SCAN_FLAG) - - -def should_skip_pre_receive_scan() -> bool: - return does_git_push_option_have_value(consts.SKIP_SCAN_FLAG) - - -def does_git_push_option_have_value(value: str) -> bool: - option_count_env_value = os.getenv(consts.GIT_PUSH_OPTION_COUNT_ENV_VAR_NAME, '') - option_count = int(option_count_env_value) if option_count_env_value.isdigit() else 0 - return any(os.getenv(f'{consts.GIT_PUSH_OPTION_ENV_VAR_PREFIX}{i}') == value for i in range(option_count)) diff --git a/cycode/cli/apps/scan/commit_history/commit_history_command.py b/cycode/cli/apps/scan/commit_history/commit_history_command.py index fc1ef23f..5935cf59 100644 --- a/cycode/cli/apps/scan/commit_history/commit_history_command.py +++ b/cycode/cli/apps/scan/commit_history/commit_history_command.py @@ -3,7 +3,7 @@ import typer -from cycode.cli.apps.scan.code_scanner import scan_commit_range +from cycode.cli.apps.scan.commit_range_scanner import scan_commit_range from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception from cycode.cli.logger import logger from cycode.cli.utils.sentry import add_breadcrumb @@ -28,6 +28,6 @@ def commit_history_command( add_breadcrumb('commit_history') logger.debug('Starting commit history scan process, %s', {'path': path, 'commit_range': commit_range}) - scan_commit_range(ctx, path=str(path), commit_range=commit_range) + scan_commit_range(ctx, repo_path=str(path), commit_range=commit_range) except Exception as e: handle_scan_exception(ctx, e) diff --git a/cycode/cli/apps/scan/commit_range_scanner.py b/cycode/cli/apps/scan/commit_range_scanner.py new file mode 100644 index 00000000..b191611f --- /dev/null +++ b/cycode/cli/apps/scan/commit_range_scanner.py @@ -0,0 +1,311 @@ +import os +from typing import TYPE_CHECKING, Optional + +import click +import typer + +from cycode.cli import consts +from cycode.cli.apps.scan.code_scanner import ( + poll_scan_results, + report_scan_status, + scan_documents, +) +from cycode.cli.apps.scan.scan_parameters import get_scan_parameters +from cycode.cli.apps.scan.scan_result import ( + create_local_scan_result, + init_default_scan_result, + print_local_scan_results, +) +from cycode.cli.config import configuration_manager +from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception +from cycode.cli.files_collector.commit_range_documents import ( + collect_commit_range_diff_documents, + get_commit_range_modified_documents, + get_diff_file_content, + get_diff_file_path, + get_pre_commit_modified_documents, + parse_commit_range_sast, + parse_commit_range_sca, +) +from cycode.cli.files_collector.file_excluder import excluder +from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip +from cycode.cli.files_collector.sca.sca_file_collector import ( + perform_sca_pre_commit_range_scan_actions, + perform_sca_pre_hook_range_scan_actions, +) +from cycode.cli.files_collector.zip_documents import zip_documents +from cycode.cli.models import Document +from cycode.cli.utils.git_proxy import git_proxy +from cycode.cli.utils.path_utils import get_path_by_os +from cycode.cli.utils.progress_bar import ScanProgressBarSection +from cycode.cli.utils.scan_utils import generate_unique_scan_id, set_issue_detected_by_scan_results +from cycode.cyclient.models import ZippedFileScanResult +from cycode.logger import get_logger + +if TYPE_CHECKING: + from cycode.cyclient.scan_client import ScanClient + +logger = get_logger('Commit Range Scanner') + + +def _does_git_push_option_have_value(value: str) -> bool: + option_count_env_value = os.getenv(consts.GIT_PUSH_OPTION_COUNT_ENV_VAR_NAME, '') + option_count = int(option_count_env_value) if option_count_env_value.isdigit() else 0 + return any(os.getenv(f'{consts.GIT_PUSH_OPTION_ENV_VAR_PREFIX}{i}') == value for i in range(option_count)) + + +def is_verbose_mode_requested_in_pre_receive_scan() -> bool: + return _does_git_push_option_have_value(consts.VERBOSE_SCAN_FLAG) + + +def should_skip_pre_receive_scan() -> bool: + return _does_git_push_option_have_value(consts.SKIP_SCAN_FLAG) + + +def _perform_commit_range_scan_async( + cycode_client: 'ScanClient', + from_commit_zipped_documents: 'InMemoryZip', + to_commit_zipped_documents: 'InMemoryZip', + scan_type: str, + scan_parameters: dict, + timeout: Optional[int] = None, +) -> ZippedFileScanResult: + scan_async_result = cycode_client.commit_range_scan_async( + from_commit_zipped_documents, to_commit_zipped_documents, scan_type, scan_parameters + ) + + logger.debug( + 'Async commit range scan request has been triggered successfully, %s', {'scan_id': scan_async_result.scan_id} + ) + return poll_scan_results(cycode_client, scan_async_result.scan_id, scan_type, scan_parameters, timeout) + + +def _scan_commit_range_documents( + ctx: typer.Context, + from_documents_to_scan: list[Document], + to_documents_to_scan: list[Document], + scan_parameters: Optional[dict] = None, + timeout: Optional[int] = None, +) -> None: + cycode_client = ctx.obj['client'] + scan_type = ctx.obj['scan_type'] + severity_threshold = ctx.obj['severity_threshold'] + scan_command_type = ctx.info_name + progress_bar = ctx.obj['progress_bar'] + + local_scan_result = error_message = None + scan_completed = False + scan_id = str(generate_unique_scan_id()) + from_commit_zipped_documents = InMemoryZip() + to_commit_zipped_documents = InMemoryZip() + + try: + progress_bar.set_section_length(ScanProgressBarSection.SCAN, 1) + + scan_result = init_default_scan_result(scan_id) + if len(from_documents_to_scan) > 0 or len(to_documents_to_scan) > 0: + logger.debug('Preparing from-commit zip') + # for SAST it is files from to_commit with actual content to scan + from_commit_zipped_documents = zip_documents(scan_type, from_documents_to_scan) + + logger.debug('Preparing to-commit zip') + # for SAST it is files with diff between from_commit and to_commit + to_commit_zipped_documents = zip_documents(scan_type, to_documents_to_scan) + + scan_result = _perform_commit_range_scan_async( + cycode_client, + from_commit_zipped_documents, + to_commit_zipped_documents, + scan_type, + scan_parameters, + timeout, + ) + + progress_bar.update(ScanProgressBarSection.SCAN) + progress_bar.set_section_length(ScanProgressBarSection.GENERATE_REPORT, 1) + + local_scan_result = create_local_scan_result( + scan_result, to_documents_to_scan, scan_command_type, scan_type, severity_threshold + ) + set_issue_detected_by_scan_results(ctx, [local_scan_result]) + + progress_bar.update(ScanProgressBarSection.GENERATE_REPORT) + progress_bar.stop() + + # errors will be handled with try-except block; printing will not occur on errors + print_local_scan_results(ctx, [local_scan_result]) + + scan_completed = True + except Exception as e: + handle_scan_exception(ctx, e) + error_message = str(e) + + zip_file_size = from_commit_zipped_documents.size + to_commit_zipped_documents.size + + detections_count = relevant_detections_count = 0 + if local_scan_result: + detections_count = local_scan_result.detections_count + relevant_detections_count = local_scan_result.relevant_detections_count + scan_id = local_scan_result.scan_id + + logger.debug( + 'Processing commit range scan results, %s', + { + 'all_violations_count': detections_count, + 'relevant_violations_count': relevant_detections_count, + 'scan_id': scan_id, + 'zip_file_size': zip_file_size, + }, + ) + report_scan_status( + cycode_client, + scan_type, + scan_id, + scan_completed, + relevant_detections_count, + detections_count, + len(to_documents_to_scan), + zip_file_size, + scan_command_type, + error_message, + ) + + +def _scan_sca_commit_range(ctx: typer.Context, repo_path: str, commit_range: str, **_) -> None: + scan_parameters = get_scan_parameters(ctx, (repo_path,)) + + from_commit_rev, to_commit_rev = parse_commit_range_sca(commit_range, repo_path) + from_commit_documents, to_commit_documents, _ = get_commit_range_modified_documents( + ctx.obj['progress_bar'], ScanProgressBarSection.PREPARE_LOCAL_FILES, repo_path, from_commit_rev, to_commit_rev + ) + from_commit_documents = excluder.exclude_irrelevant_documents_to_scan(consts.SCA_SCAN_TYPE, from_commit_documents) + to_commit_documents = excluder.exclude_irrelevant_documents_to_scan(consts.SCA_SCAN_TYPE, to_commit_documents) + + perform_sca_pre_commit_range_scan_actions( + repo_path, from_commit_documents, from_commit_rev, to_commit_documents, to_commit_rev + ) + + _scan_commit_range_documents(ctx, from_commit_documents, to_commit_documents, scan_parameters=scan_parameters) + + +def _scan_secret_commit_range( + ctx: typer.Context, repo_path: str, commit_range: str, max_commits_count: Optional[int] = None +) -> None: + commit_diff_documents_to_scan = collect_commit_range_diff_documents(ctx, repo_path, commit_range, max_commits_count) + diff_documents_to_scan = excluder.exclude_irrelevant_documents_to_scan( + consts.SECRET_SCAN_TYPE, commit_diff_documents_to_scan + ) + + scan_documents( + ctx, diff_documents_to_scan, get_scan_parameters(ctx, (repo_path,)), is_git_diff=True, is_commit_range=True + ) + + +def _scan_sast_commit_range(ctx: typer.Context, repo_path: str, commit_range: str, **_) -> None: + scan_parameters = get_scan_parameters(ctx, (repo_path,)) + + from_commit_rev, to_commit_rev = parse_commit_range_sast(commit_range, repo_path) + _, commit_documents, diff_documents = get_commit_range_modified_documents( + ctx.obj['progress_bar'], + ScanProgressBarSection.PREPARE_LOCAL_FILES, + repo_path, + from_commit_rev, + to_commit_rev, + reverse_diff=False, + ) + commit_documents = excluder.exclude_irrelevant_documents_to_scan(consts.SAST_SCAN_TYPE, commit_documents) + diff_documents = excluder.exclude_irrelevant_documents_to_scan(consts.SAST_SCAN_TYPE, diff_documents) + + _scan_commit_range_documents(ctx, commit_documents, diff_documents, scan_parameters=scan_parameters) + + +_SCAN_TYPE_TO_COMMIT_RANGE_HANDLER = { + consts.SCA_SCAN_TYPE: _scan_sca_commit_range, + consts.SECRET_SCAN_TYPE: _scan_secret_commit_range, + consts.SAST_SCAN_TYPE: _scan_sast_commit_range, +} + + +def scan_commit_range(ctx: typer.Context, repo_path: str, commit_range: str, **kwargs) -> None: + scan_type = ctx.obj['scan_type'] + + progress_bar = ctx.obj['progress_bar'] + progress_bar.start() + + if scan_type not in _SCAN_TYPE_TO_COMMIT_RANGE_HANDLER: + raise click.ClickException(f'Commit range scanning for {scan_type.upper()} is not supported') + + _SCAN_TYPE_TO_COMMIT_RANGE_HANDLER[scan_type](ctx, repo_path, commit_range, **kwargs) + + +def _scan_sca_pre_commit(ctx: typer.Context, repo_path: str) -> None: + scan_parameters = get_scan_parameters(ctx) + + git_head_documents, pre_committed_documents, _ = get_pre_commit_modified_documents( + progress_bar=ctx.obj['progress_bar'], + progress_bar_section=ScanProgressBarSection.PREPARE_LOCAL_FILES, + repo_path=repo_path, + ) + git_head_documents = excluder.exclude_irrelevant_documents_to_scan(consts.SCA_SCAN_TYPE, git_head_documents) + pre_committed_documents = excluder.exclude_irrelevant_documents_to_scan( + consts.SCA_SCAN_TYPE, pre_committed_documents + ) + + perform_sca_pre_hook_range_scan_actions(repo_path, git_head_documents, pre_committed_documents) + + _scan_commit_range_documents( + ctx, + git_head_documents, + pre_committed_documents, + scan_parameters, + configuration_manager.get_sca_pre_commit_timeout_in_seconds(), + ) + + +def _scan_secret_pre_commit(ctx: typer.Context, repo_path: str) -> None: + progress_bar = ctx.obj['progress_bar'] + diff_index = git_proxy.get_repo(repo_path).index.diff(consts.GIT_HEAD_COMMIT_REV, create_patch=True, R=True) + + progress_bar.set_section_length(ScanProgressBarSection.PREPARE_LOCAL_FILES, len(diff_index)) + + documents_to_scan = [] + for diff in diff_index: + progress_bar.update(ScanProgressBarSection.PREPARE_LOCAL_FILES) + documents_to_scan.append( + Document(get_path_by_os(get_diff_file_path(diff)), get_diff_file_content(diff), is_git_diff_format=True) + ) + documents_to_scan = excluder.exclude_irrelevant_documents_to_scan(consts.SECRET_SCAN_TYPE, documents_to_scan) + + scan_documents(ctx, documents_to_scan, get_scan_parameters(ctx), is_git_diff=True) + + +def _scan_sast_pre_commit(ctx: typer.Context, repo_path: str, **_) -> None: + scan_parameters = get_scan_parameters(ctx, (repo_path,)) + + _, pre_committed_documents, diff_documents = get_pre_commit_modified_documents( + progress_bar=ctx.obj['progress_bar'], + progress_bar_section=ScanProgressBarSection.PREPARE_LOCAL_FILES, + repo_path=repo_path, + ) + pre_committed_documents = excluder.exclude_irrelevant_documents_to_scan( + consts.SAST_SCAN_TYPE, pre_committed_documents + ) + diff_documents = excluder.exclude_irrelevant_documents_to_scan(consts.SAST_SCAN_TYPE, diff_documents) + + _scan_commit_range_documents(ctx, pre_committed_documents, diff_documents, scan_parameters=scan_parameters) + + +_SCAN_TYPE_TO_PRE_COMMIT_HANDLER = { + consts.SCA_SCAN_TYPE: _scan_sca_pre_commit, + consts.SECRET_SCAN_TYPE: _scan_secret_pre_commit, + consts.SAST_SCAN_TYPE: _scan_sast_pre_commit, +} + + +def scan_pre_commit(ctx: typer.Context, repo_path: str) -> None: + scan_type = ctx.obj['scan_type'] + if scan_type not in _SCAN_TYPE_TO_PRE_COMMIT_HANDLER: + raise click.ClickException(f'Pre-commit scanning for {scan_type.upper()} is not supported') + + _SCAN_TYPE_TO_PRE_COMMIT_HANDLER[scan_type](ctx, repo_path) + logger.debug('Pre-commit scan completed successfully') diff --git a/cycode/cli/apps/scan/detection_excluder.py b/cycode/cli/apps/scan/detection_excluder.py new file mode 100644 index 00000000..3697bcaf --- /dev/null +++ b/cycode/cli/apps/scan/detection_excluder.py @@ -0,0 +1,153 @@ +from typing import Optional + +from cycode.cli import consts +from cycode.cli.cli_types import SeverityOption +from cycode.cli.config import configuration_manager +from cycode.cli.models import DocumentDetections +from cycode.cyclient.models import Detection +from cycode.logger import get_logger + +logger = get_logger('Detection Excluder') + + +def _does_severity_match_severity_threshold(severity: str, severity_threshold: str) -> bool: + detection_severity_value = SeverityOption.get_member_weight(severity) + severity_threshold_value = SeverityOption.get_member_weight(severity_threshold) + if detection_severity_value < 0 or severity_threshold_value < 0: + return True + + return detection_severity_value >= severity_threshold_value + + +def _exclude_irrelevant_detections( + detections: list[Detection], scan_type: str, command_scan_type: str, severity_threshold: str +) -> list[Detection]: + relevant_detections = _exclude_detections_by_exclusions_configuration(detections, scan_type) + relevant_detections = _exclude_detections_by_scan_type(relevant_detections, scan_type, command_scan_type) + return _exclude_detections_by_severity(relevant_detections, severity_threshold) + + +def _exclude_detections_by_severity(detections: list[Detection], severity_threshold: str) -> list[Detection]: + relevant_detections = [] + for detection in detections: + severity = detection.severity + + if _does_severity_match_severity_threshold(severity, severity_threshold): + relevant_detections.append(detection) + else: + logger.debug( + 'Going to ignore violations because they are below the severity threshold, %s', + {'severity': severity, 'severity_threshold': severity_threshold}, + ) + + return relevant_detections + + +def _exclude_detections_by_scan_type( + detections: list[Detection], scan_type: str, command_scan_type: str +) -> list[Detection]: + if command_scan_type == consts.PRE_COMMIT_COMMAND_SCAN_TYPE: + return _exclude_detections_in_deleted_lines(detections) + + exclude_in_deleted_lines = configuration_manager.get_should_exclude_detections_in_deleted_lines(command_scan_type) + if ( + command_scan_type in consts.COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES + and scan_type == consts.SECRET_SCAN_TYPE + and exclude_in_deleted_lines + ): + return _exclude_detections_in_deleted_lines(detections) + + return detections + + +def _exclude_detections_in_deleted_lines(detections: list[Detection]) -> list[Detection]: + return [detection for detection in detections if detection.detection_details.get('line_type') != 'Removed'] + + +def _exclude_detections_by_exclusions_configuration(detections: list[Detection], scan_type: str) -> list[Detection]: + exclusions = configuration_manager.get_exclusions_by_scan_type(scan_type) + return [detection for detection in detections if not _should_exclude_detection(detection, exclusions)] + + +def _should_exclude_detection(detection: Detection, exclusions: dict) -> bool: + # FIXME(MarshalX): what the difference between by_value and by_sha? + exclusions_by_value = exclusions.get(consts.EXCLUSIONS_BY_VALUE_SECTION_NAME, []) + if _is_detection_sha_configured_in_exclusions(detection, exclusions_by_value): + logger.debug( + 'Ignoring violation because its value is on the ignore list, %s', + {'value_sha': detection.detection_details.get('sha512')}, + ) + return True + + exclusions_by_sha = exclusions.get(consts.EXCLUSIONS_BY_SHA_SECTION_NAME, []) + if _is_detection_sha_configured_in_exclusions(detection, exclusions_by_sha): + logger.debug( + 'Ignoring violation because its SHA value is on the ignore list, %s', + {'sha': detection.detection_details.get('sha512')}, + ) + return True + + exclusions_by_rule = exclusions.get(consts.EXCLUSIONS_BY_RULE_SECTION_NAME, []) + detection_rule_id = detection.detection_rule_id + if detection_rule_id in exclusions_by_rule: + logger.debug( + 'Ignoring violation because its Detection Rule ID is on the ignore list, %s', + {'detection_rule_id': detection_rule_id}, + ) + return True + + exclusions_by_package = exclusions.get(consts.EXCLUSIONS_BY_PACKAGE_SECTION_NAME, []) + package = _get_package_name(detection) + if package and package in exclusions_by_package: + logger.debug('Ignoring violation because its package@version is on the ignore list, %s', {'package': package}) + return True + + exclusions_by_cve = exclusions.get(consts.EXCLUSIONS_BY_CVE_SECTION_NAME, []) + cve = _get_cve_identifier(detection) + if cve and cve in exclusions_by_cve: + logger.debug('Ignoring violation because its CVE is on the ignore list, %s', {'cve': cve}) + return True + + return False + + +def _is_detection_sha_configured_in_exclusions(detection: Detection, exclusions: list[str]) -> bool: + detection_sha = detection.detection_details.get('sha512') + return detection_sha in exclusions + + +def _get_package_name(detection: Detection) -> Optional[str]: + package_name = detection.detection_details.get('vulnerable_component') + package_version = detection.detection_details.get('vulnerable_component_version') + + if package_name is None: + package_name = detection.detection_details.get('package_name') + package_version = detection.detection_details.get('package_version') + + if package_name and package_version: + return f'{package_name}@{package_version}' + + return None + + +def _get_cve_identifier(detection: Detection) -> Optional[str]: + return detection.detection_details.get('alert', {}).get('cve_identifier') + + +def exclude_irrelevant_document_detections( + document_detections_list: list[DocumentDetections], + scan_type: str, + command_scan_type: str, + severity_threshold: str, +) -> list[DocumentDetections]: + relevant_document_detections_list = [] + for document_detections in document_detections_list: + relevant_detections = _exclude_irrelevant_detections( + document_detections.detections, scan_type, command_scan_type, severity_threshold + ) + if relevant_detections: + relevant_document_detections_list.append( + DocumentDetections(document=document_detections.document, detections=relevant_detections) + ) + + return relevant_document_detections_list diff --git a/cycode/cli/apps/scan/pre_commit/pre_commit_command.py b/cycode/cli/apps/scan/pre_commit/pre_commit_command.py index 9242b450..5693412f 100644 --- a/cycode/cli/apps/scan/pre_commit/pre_commit_command.py +++ b/cycode/cli/apps/scan/pre_commit/pre_commit_command.py @@ -3,19 +3,7 @@ import typer -from cycode.cli import consts -from cycode.cli.apps.scan.code_scanner import get_scan_parameters, scan_documents, scan_sca_pre_commit -from cycode.cli.files_collector.excluder import excluder -from cycode.cli.files_collector.repository_documents import ( - get_diff_file_content, - get_diff_file_path, -) -from cycode.cli.models import Document -from cycode.cli.utils.git_proxy import git_proxy -from cycode.cli.utils.path_utils import ( - get_path_by_os, -) -from cycode.cli.utils.progress_bar import ScanProgressBarSection +from cycode.cli.apps.scan.commit_range_scanner import scan_pre_commit from cycode.cli.utils.sentry import add_breadcrumb @@ -25,25 +13,9 @@ def pre_commit_command( ) -> None: add_breadcrumb('pre_commit') - scan_type = ctx.obj['scan_type'] - repo_path = os.getcwd() # change locally for easy testing progress_bar = ctx.obj['progress_bar'] progress_bar.start() - if scan_type == consts.SCA_SCAN_TYPE: - scan_sca_pre_commit(ctx, repo_path) - return - - diff_files = git_proxy.get_repo(repo_path).index.diff(consts.GIT_HEAD_COMMIT_REV, create_patch=True, R=True) - - progress_bar.set_section_length(ScanProgressBarSection.PREPARE_LOCAL_FILES, len(diff_files)) - - documents_to_scan = [] - for file in diff_files: - progress_bar.update(ScanProgressBarSection.PREPARE_LOCAL_FILES) - documents_to_scan.append(Document(get_path_by_os(get_diff_file_path(file)), get_diff_file_content(file))) - - documents_to_scan = excluder.exclude_irrelevant_documents_to_scan(scan_type, documents_to_scan) - scan_documents(ctx, documents_to_scan, get_scan_parameters(ctx), is_git_diff=True) + scan_pre_commit(ctx, repo_path) diff --git a/cycode/cli/apps/scan/pre_receive/pre_receive_command.py b/cycode/cli/apps/scan/pre_receive/pre_receive_command.py index eb4f1420..ef30ee8f 100644 --- a/cycode/cli/apps/scan/pre_receive/pre_receive_command.py +++ b/cycode/cli/apps/scan/pre_receive/pre_receive_command.py @@ -1,26 +1,27 @@ +import logging import os from typing import Annotated, Optional -import click import typer from cycode.cli import consts -from cycode.cli.apps.scan.code_scanner import ( - enable_verbose_mode, +from cycode.cli.apps.scan.commit_range_scanner import ( is_verbose_mode_requested_in_pre_receive_scan, - parse_pre_receive_input, - perform_post_pre_receive_scan_actions, scan_commit_range, should_skip_pre_receive_scan, ) from cycode.cli.config import configuration_manager +from cycode.cli.console import console from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception -from cycode.cli.files_collector.repository_documents import ( +from cycode.cli.files_collector.commit_range_documents import ( calculate_pre_receive_commit_range, + parse_pre_receive_input, ) from cycode.cli.logger import logger +from cycode.cli.utils import scan_utils from cycode.cli.utils.sentry import add_breadcrumb from cycode.cli.utils.task_timer import TimeoutAfter +from cycode.logger import set_logging_level def pre_receive_command( @@ -30,10 +31,6 @@ def pre_receive_command( try: add_breadcrumb('pre_receive') - scan_type = ctx.obj['scan_type'] - if scan_type != consts.SECRET_SCAN_TYPE: - raise click.ClickException(f'Commit range scanning for {scan_type.upper()} is not supported') - if should_skip_pre_receive_scan(): logger.info( 'A scan has been skipped as per your request. ' @@ -42,15 +39,13 @@ def pre_receive_command( return if is_verbose_mode_requested_in_pre_receive_scan(): - enable_verbose_mode(ctx) + ctx.obj['verbose'] = True + set_logging_level(logging.DEBUG) logger.debug('Verbose mode enabled: all log levels will be displayed.') command_scan_type = ctx.info_name timeout = configuration_manager.get_pre_receive_command_timeout(command_scan_type) with TimeoutAfter(timeout): - if scan_type not in consts.COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES: - raise click.ClickException(f'Commit range scanning for {scan_type.upper()} is not supported') - branch_update_details = parse_pre_receive_input() commit_range = calculate_pre_receive_commit_range(branch_update_details) if not commit_range: @@ -60,8 +55,14 @@ def pre_receive_command( ) return - max_commits_to_scan = configuration_manager.get_pre_receive_max_commits_to_scan_count(command_scan_type) - scan_commit_range(ctx, os.getcwd(), commit_range, max_commits_count=max_commits_to_scan) - perform_post_pre_receive_scan_actions(ctx) + scan_commit_range( + ctx=ctx, + repo_path=os.getcwd(), + commit_range=commit_range, + max_commits_count=configuration_manager.get_pre_receive_max_commits_to_scan_count(command_scan_type), + ) + + if scan_utils.is_scan_failed(ctx): + console.print(consts.PRE_RECEIVE_REMEDIATION_MESSAGE) except Exception as e: handle_scan_exception(ctx, e) diff --git a/cycode/cli/apps/scan/remote_url_resolver.py b/cycode/cli/apps/scan/remote_url_resolver.py new file mode 100644 index 00000000..5f96328d --- /dev/null +++ b/cycode/cli/apps/scan/remote_url_resolver.py @@ -0,0 +1,115 @@ +from typing import Optional + +from cycode.cli import consts +from cycode.cli.utils.git_proxy import git_proxy +from cycode.cli.utils.shell_executor import shell +from cycode.logger import get_logger + +logger = get_logger('Remote URL Resolver') + + +def _get_plastic_repository_name(path: str) -> Optional[str]: + """Get the name of the Plastic repository from the current working directory. + + The command to execute is: + cm status --header --machinereadable --fieldseparator=":::" + + Example of status header in machine-readable format: + STATUS:::0:::Project/RepoName:::OrgName@ServerInfo + """ + try: + command = [ + 'cm', + 'status', + '--header', + '--machinereadable', + f'--fieldseparator={consts.PLASTIC_VCS_DATA_SEPARATOR}', + ] + + status = shell( + command=command, timeout=consts.PLASTIC_VSC_CLI_TIMEOUT, working_directory=path, silent_exc_info=True + ) + if not status: + logger.debug('Failed to get Plastic repository name (command failed)') + return None + + status_parts = status.split(consts.PLASTIC_VCS_DATA_SEPARATOR) + if len(status_parts) < 2: + logger.debug('Failed to parse Plastic repository name (command returned unexpected format)') + return None + + return status_parts[2].strip() + except Exception: + logger.debug('Failed to get Plastic repository name. Probably not a Plastic repository') + return None + + +def _get_plastic_repository_list(working_dir: Optional[str] = None) -> dict[str, str]: + """Get the list of Plastic repositories and their GUIDs. + + The command to execute is: + cm repo list --format="{repname}:::{repguid}" + + Example line with data: + Project/RepoName:::tapo1zqt-wn99-4752-h61m-7d9k79d40r4v + + Each line represents an individual repository. + """ + repo_name_to_guid = {} + + try: + command = ['cm', 'repo', 'ls', f'--format={{repname}}{consts.PLASTIC_VCS_DATA_SEPARATOR}{{repguid}}'] + + status = shell( + command=command, timeout=consts.PLASTIC_VSC_CLI_TIMEOUT, working_directory=working_dir, silent_exc_info=True + ) + if not status: + logger.debug('Failed to get Plastic repository list (command failed)') + return repo_name_to_guid + + status_lines = status.splitlines() + for line in status_lines: + data_parts = line.split(consts.PLASTIC_VCS_DATA_SEPARATOR) + if len(data_parts) < 2: + logger.debug('Failed to parse Plastic repository list line (unexpected format), %s', {'line': line}) + continue + + repo_name, repo_guid = data_parts + repo_name_to_guid[repo_name.strip()] = repo_guid.strip() + + return repo_name_to_guid + except Exception as e: + logger.debug('Failed to get Plastic repository list', exc_info=e) + return repo_name_to_guid + + +def _try_to_get_plastic_remote_url(path: str) -> Optional[str]: + repository_name = _get_plastic_repository_name(path) + if not repository_name: + return None + + repository_map = _get_plastic_repository_list(path) + if repository_name not in repository_map: + logger.debug('Failed to get Plastic repository GUID (repository not found in the list)') + return None + + repository_guid = repository_map[repository_name] + return f'{consts.PLASTIC_VCS_REMOTE_URI_PREFIX}{repository_guid}' + + +def _try_get_git_remote_url(path: str) -> Optional[str]: + try: + remote_url = git_proxy.get_repo(path).remotes[0].config_reader.get('url') + logger.debug('Found Git remote URL, %s', {'remote_url': remote_url, 'path': path}) + return remote_url + except Exception: + logger.debug('Failed to get Git remote URL. Probably not a Git repository') + return None + + +def try_get_any_remote_url(path: str) -> Optional[str]: + remote_url = _try_get_git_remote_url(path) + if not remote_url: + remote_url = _try_to_get_plastic_remote_url(path) + + return remote_url diff --git a/cycode/cli/apps/scan/repository/repository_command.py b/cycode/cli/apps/scan/repository/repository_command.py index e9a3f63d..6fc77bee 100644 --- a/cycode/cli/apps/scan/repository/repository_command.py +++ b/cycode/cli/apps/scan/repository/repository_command.py @@ -5,11 +5,12 @@ import typer from cycode.cli import consts -from cycode.cli.apps.scan.code_scanner import get_scan_parameters, scan_documents +from cycode.cli.apps.scan.code_scanner import scan_documents +from cycode.cli.apps.scan.scan_parameters import get_scan_parameters from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception -from cycode.cli.files_collector.excluder import excluder +from cycode.cli.files_collector.file_excluder import excluder 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.files_collector.sca.sca_file_collector import add_sca_dependencies_tree_documents_if_needed from cycode.cli.logger import logger from cycode.cli.models import Document from cycode.cli.utils.path_utils import get_path_by_os @@ -59,7 +60,7 @@ def repository_command( documents_to_scan = excluder.exclude_irrelevant_documents_to_scan(scan_type, documents_to_scan) - perform_pre_scan_documents_actions(ctx, scan_type, documents_to_scan) + add_sca_dependencies_tree_documents_if_needed(ctx, scan_type, documents_to_scan) logger.debug('Found all relevant files for scanning %s', {'path': path, 'branch': branch}) scan_documents(ctx, documents_to_scan, get_scan_parameters(ctx, (str(path),))) diff --git a/cycode/cli/apps/scan/scan_ci/scan_ci_command.py b/cycode/cli/apps/scan/scan_ci/scan_ci_command.py index cbfebb72..4303cda2 100644 --- a/cycode/cli/apps/scan/scan_ci/scan_ci_command.py +++ b/cycode/cli/apps/scan/scan_ci/scan_ci_command.py @@ -3,7 +3,7 @@ import click import typer -from cycode.cli.apps.scan.code_scanner import scan_commit_range +from cycode.cli.apps.scan.commit_range_scanner import scan_commit_range from cycode.cli.apps.scan.scan_ci.ci_integrations import get_commit_range from cycode.cli.utils.sentry import add_breadcrumb @@ -17,4 +17,4 @@ @click.pass_context def scan_ci_command(ctx: typer.Context) -> None: add_breadcrumb('ci') - scan_commit_range(ctx, path=os.getcwd(), commit_range=get_commit_range()) + scan_commit_range(ctx, repo_path=os.getcwd(), commit_range=get_commit_range()) diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 7c2de1a6..363c409a 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -9,7 +9,7 @@ ISSUE_DETECTED_STATUS_CODE, NO_ISSUES_STATUS_CODE, ) -from cycode.cli.files_collector.excluder import excluder +from cycode.cli.files_collector.file_excluder import excluder from cycode.cli.utils import scan_utils from cycode.cli.utils.get_api_client import get_scan_cycode_client from cycode.cli.utils.sentry import add_breadcrumb diff --git a/cycode/cli/apps/scan/scan_parameters.py b/cycode/cli/apps/scan/scan_parameters.py new file mode 100644 index 00000000..c3c4ecbe --- /dev/null +++ b/cycode/cli/apps/scan/scan_parameters.py @@ -0,0 +1,46 @@ +import os +from typing import Optional + +import typer + +from cycode.cli.apps.scan.remote_url_resolver import try_get_any_remote_url +from cycode.cli.utils.scan_utils import generate_unique_scan_id +from cycode.logger import get_logger + +logger = get_logger('Scan Parameters') + + +def _get_default_scan_parameters(ctx: typer.Context) -> dict: + return { + 'monitor': ctx.obj.get('monitor'), + 'report': ctx.obj.get('report'), + 'package_vulnerabilities': ctx.obj.get('package-vulnerabilities'), + 'license_compliance': ctx.obj.get('license-compliance'), + 'command_type': ctx.info_name.replace('-', '_'), # save backward compatibility + 'aggregation_id': str(generate_unique_scan_id()), + } + + +def get_scan_parameters(ctx: typer.Context, paths: Optional[tuple[str, ...]] = None) -> dict: + scan_parameters = _get_default_scan_parameters(ctx) + + if not paths: + return scan_parameters + + scan_parameters['paths'] = paths + + if len(paths) != 1: + logger.debug('Multiple paths provided, going to ignore remote url') + return scan_parameters + + if not os.path.isdir(paths[0]): + logger.debug('Path is not a directory, going to ignore remote url') + return scan_parameters + + remote_url = try_get_any_remote_url(paths[0]) + if remote_url: + # TODO(MarshalX): remove hardcode in context + ctx.obj['remote_url'] = remote_url + scan_parameters['remote_url'] = remote_url + + return scan_parameters diff --git a/cycode/cli/apps/scan/scan_result.py b/cycode/cli/apps/scan/scan_result.py new file mode 100644 index 00000000..88bc6320 --- /dev/null +++ b/cycode/cli/apps/scan/scan_result.py @@ -0,0 +1,181 @@ +import os +from typing import TYPE_CHECKING, Optional + +import typer + +from cycode.cli import consts +from cycode.cli.apps.scan.aggregation_report import try_get_aggregation_report_url_if_needed +from cycode.cli.apps.scan.detection_excluder import exclude_irrelevant_document_detections +from cycode.cli.models import Document, DocumentDetections, LocalScanResult +from cycode.cli.utils.path_utils import get_path_by_os, normalize_file_path +from cycode.cyclient.models import ( + Detection, + DetectionSchema, + DetectionsPerFile, + ScanResultsSyncFlow, + ZippedFileScanResult, +) +from cycode.logger import get_logger + +if TYPE_CHECKING: + from cycode.cli.models import CliError + from cycode.cyclient.models import ScanDetailsResponse + from cycode.cyclient.scan_client import ScanClient + +logger = get_logger('Scan Results') + + +def _get_document_by_file_name( + documents: list[Document], file_name: str, unique_id: Optional[str] = None +) -> Optional[Document]: + for document in documents: + if normalize_file_path(document.path) == normalize_file_path(file_name) and document.unique_id == unique_id: + return document + + return None + + +def _get_document_detections( + scan_result: ZippedFileScanResult, documents_to_scan: list[Document] +) -> list[DocumentDetections]: + logger.debug('Getting document detections') + + document_detections = [] + for detections_per_file in scan_result.detections_per_file: + file_name = get_path_by_os(detections_per_file.file_name) + commit_id = detections_per_file.commit_id + + logger.debug( + 'Going to find the document of the violated file, %s', {'file_name': file_name, 'commit_id': commit_id} + ) + + document = _get_document_by_file_name(documents_to_scan, file_name, commit_id) + document_detections.append(DocumentDetections(document=document, detections=detections_per_file.detections)) + + return document_detections + + +def create_local_scan_result( + scan_result: ZippedFileScanResult, + documents_to_scan: list[Document], + command_scan_type: str, + scan_type: str, + severity_threshold: str, +) -> LocalScanResult: + document_detections = _get_document_detections(scan_result, documents_to_scan) + relevant_document_detections_list = exclude_irrelevant_document_detections( + document_detections, scan_type, command_scan_type, severity_threshold + ) + + detections_count = sum([len(document_detection.detections) for document_detection in document_detections]) + relevant_detections_count = sum( + [len(document_detections.detections) for document_detections in relevant_document_detections_list] + ) + + return LocalScanResult( + scan_id=scan_result.scan_id, + report_url=scan_result.report_url, + document_detections=relevant_document_detections_list, + issue_detected=len(relevant_document_detections_list) > 0, + detections_count=detections_count, + relevant_detections_count=relevant_detections_count, + ) + + +def _get_file_name_from_detection(scan_type: str, raw_detection: dict) -> str: + if scan_type == consts.SAST_SCAN_TYPE: + return raw_detection['detection_details']['file_path'] + if scan_type == consts.SECRET_SCAN_TYPE: + return _get_secret_file_name_from_detection(raw_detection) + + return raw_detection['detection_details']['file_name'] + + +def _get_secret_file_name_from_detection(raw_detection: dict) -> str: + file_path: str = raw_detection['detection_details']['file_path'] + file_name: str = raw_detection['detection_details']['file_name'] + return os.path.join(file_path, file_name) + + +def _map_detections_per_file_and_commit_id(scan_type: str, raw_detections: list[dict]) -> list[DetectionsPerFile]: + """Convert a list of detections (async flow) to list of DetectionsPerFile objects (sync flow). + + Args: + scan_type: Type of the scan. + raw_detections: List of detections as is returned from the server. + + Note: + This method fakes server response structure + to be able to use the same logic for both async and sync scans. + + Note: + Aggregation is performed by file name and commit ID (if available) + + """ + detections_per_files = {} + for raw_detection in raw_detections: + try: + # FIXME(MarshalX): investigate this field mapping + raw_detection['message'] = raw_detection['correlation_message'] + + file_name = _get_file_name_from_detection(scan_type, raw_detection) + detection: Detection = DetectionSchema().load(raw_detection) + commit_id: Optional[str] = detection.detection_details.get('commit_id') # could be None + group_by_key = (file_name, commit_id) + + if group_by_key in detections_per_files: + detections_per_files[group_by_key].append(detection) + else: + detections_per_files[group_by_key] = [detection] + except Exception as e: + logger.debug('Failed to parse detection', exc_info=e) + continue + + return [ + DetectionsPerFile(file_name=file_name, detections=file_detections, commit_id=commit_id) + for (file_name, commit_id), file_detections in detections_per_files.items() + ] + + +def init_default_scan_result(scan_id: str) -> ZippedFileScanResult: + return ZippedFileScanResult( + did_detect=False, + detections_per_file=[], + scan_id=scan_id, + ) + + +def get_scan_result( + cycode_client: 'ScanClient', + scan_type: str, + scan_id: str, + scan_details: 'ScanDetailsResponse', + scan_parameters: dict, +) -> ZippedFileScanResult: + if not scan_details.detections_count: + return init_default_scan_result(scan_id) + + scan_raw_detections = cycode_client.get_scan_raw_detections(scan_id) + + return ZippedFileScanResult( + did_detect=True, + detections_per_file=_map_detections_per_file_and_commit_id(scan_type, scan_raw_detections), + scan_id=scan_id, + report_url=try_get_aggregation_report_url_if_needed(scan_parameters, cycode_client, scan_type), + ) + + +def get_sync_scan_result(scan_type: str, scan_results: 'ScanResultsSyncFlow') -> ZippedFileScanResult: + return ZippedFileScanResult( + did_detect=True, + detections_per_file=_map_detections_per_file_and_commit_id(scan_type, scan_results.detection_messages), + scan_id=scan_results.id, + ) + + +def print_local_scan_results( + ctx: typer.Context, local_scan_results: list[LocalScanResult], errors: Optional[dict[str, 'CliError']] = None +) -> None: + printer = ctx.obj.get('console_printer') + printer.update_ctx(ctx) + printer.print_scan_results(local_scan_results, errors) diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index c0ed33f0..74a9758c 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -140,9 +140,11 @@ 'conan': ['conanfile.py', 'conanfile.txt', 'conan.lock'], } -COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES = [SECRET_SCAN_TYPE, SCA_SCAN_TYPE] +COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES = [SECRET_SCAN_TYPE, SCA_SCAN_TYPE, SAST_SCAN_TYPE] COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES = [ + PRE_COMMIT_COMMAND_SCAN_TYPE, + PRE_COMMIT_COMMAND_SCAN_TYPE_OLD, PRE_RECEIVE_COMMAND_SCAN_TYPE, PRE_RECEIVE_COMMAND_SCAN_TYPE_OLD, COMMIT_HISTORY_COMMAND_SCAN_TYPE, diff --git a/cycode/cli/files_collector/commit_range_documents.py b/cycode/cli/files_collector/commit_range_documents.py new file mode 100644 index 00000000..68d18978 --- /dev/null +++ b/cycode/cli/files_collector/commit_range_documents.py @@ -0,0 +1,289 @@ +import os +import sys +from typing import TYPE_CHECKING, Optional + +import typer + +from cycode.cli import consts +from cycode.cli.files_collector.repository_documents import ( + get_file_content_from_commit_path, +) +from cycode.cli.models import Document +from cycode.cli.utils.git_proxy import git_proxy +from cycode.cli.utils.path_utils import get_file_content, get_path_by_os +from cycode.cli.utils.progress_bar import ScanProgressBarSection +from cycode.logger import get_logger + +if TYPE_CHECKING: + from git import Diff, Repo + + from cycode.cli.utils.progress_bar import BaseProgressBar, ProgressBarSection + +logger = get_logger('Commit Range Collector') + + +def _does_reach_to_max_commits_to_scan_limit(commit_ids: list[str], max_commits_count: Optional[int]) -> bool: + if max_commits_count is None: + return False + + return len(commit_ids) >= max_commits_count + + +def collect_commit_range_diff_documents( + ctx: typer.Context, path: str, commit_range: str, max_commits_count: Optional[int] = None +) -> list[Document]: + """Collects documents from a specified commit range in a Git repository. + + Return a list of Document objects containing the diffs of files changed in each commit. + """ + progress_bar = ctx.obj['progress_bar'] + + commit_ids_to_scan = [] + commit_documents_to_scan = [] + + repo = git_proxy.get_repo(path) + total_commits_count = int(repo.git.rev_list('--count', commit_range)) + logger.debug('Calculating diffs for %s commits in the commit range %s', total_commits_count, commit_range) + + progress_bar.set_section_length(ScanProgressBarSection.PREPARE_LOCAL_FILES, total_commits_count) + + for scanned_commits_count, commit in enumerate(repo.iter_commits(rev=commit_range)): + if _does_reach_to_max_commits_to_scan_limit(commit_ids_to_scan, max_commits_count): + logger.debug('Reached to max commits to scan count. Going to scan only %s last commits', max_commits_count) + progress_bar.update(ScanProgressBarSection.PREPARE_LOCAL_FILES, total_commits_count - scanned_commits_count) + break + + progress_bar.update(ScanProgressBarSection.PREPARE_LOCAL_FILES) + + commit_id = commit.hexsha + commit_ids_to_scan.append(commit_id) + parent = commit.parents[0] if commit.parents else git_proxy.get_null_tree() + diff_index = commit.diff(parent, create_patch=True, R=True) + for diff in diff_index: + commit_documents_to_scan.append( + Document( + path=get_path_by_os(get_diff_file_path(diff)), + content=get_diff_file_content(diff), + is_git_diff_format=True, + unique_id=commit_id, + ) + ) + + logger.debug( + 'Found all relevant files in commit %s', + {'path': path, 'commit_range': commit_range, 'commit_id': commit_id}, + ) + + logger.debug('List of commit ids to scan, %s', {'commit_ids': commit_ids_to_scan}) + + return commit_documents_to_scan + + +def calculate_pre_receive_commit_range(branch_update_details: str) -> Optional[str]: + end_commit = _get_end_commit_from_branch_update_details(branch_update_details) + + # branch is deleted, no need to perform scan + if end_commit == consts.EMPTY_COMMIT_SHA: + return None + + start_commit = _get_oldest_unupdated_commit_for_branch(end_commit) + + # no new commit to update found + if not start_commit: + return None + + return f'{start_commit}~1...{end_commit}' + + +def _get_end_commit_from_branch_update_details(update_details: str) -> str: + # update details pattern: + _, end_commit, _ = update_details.split() + return end_commit + + +def _get_oldest_unupdated_commit_for_branch(commit: str) -> Optional[str]: + # get a list of commits by chronological order that are not in the remote repository yet + # more info about rev-list command: https://git-scm.com/docs/git-rev-list + repo = git_proxy.get_repo(os.getcwd()) + not_updated_commits = repo.git.rev_list(commit, '--topo-order', '--reverse', '--not', '--all') + + commits = not_updated_commits.splitlines() + if not commits: + return None + + return commits[0] + + +def _get_file_content_from_commit_diff(repo: 'Repo', commit: str, diff: 'Diff') -> Optional[str]: + file_path = get_diff_file_path(diff, relative=True) + return get_file_content_from_commit_path(repo, commit, file_path) + + +def get_commit_range_modified_documents( + progress_bar: 'BaseProgressBar', + progress_bar_section: 'ProgressBarSection', + path: str, + from_commit_rev: str, + to_commit_rev: str, + reverse_diff: bool = True, +) -> tuple[list[Document], list[Document], list[Document]]: + from_commit_documents = [] + to_commit_documents = [] + diff_documents = [] + + repo = git_proxy.get_repo(path) + diff_index = repo.commit(from_commit_rev).diff(to_commit_rev, create_patch=True, R=reverse_diff) + + modified_files_diff = [ + diff for diff in diff_index if diff.change_type != consts.COMMIT_DIFF_DELETED_FILE_CHANGE_TYPE + ] + progress_bar.set_section_length(progress_bar_section, len(modified_files_diff)) + for diff in modified_files_diff: + progress_bar.update(progress_bar_section) + + file_path = get_path_by_os(get_diff_file_path(diff)) + + diff_documents.append( + Document( + path=file_path, + content=get_diff_file_content(diff), + is_git_diff_format=True, + ) + ) + + file_content = _get_file_content_from_commit_diff(repo, from_commit_rev, diff) + if file_content is not None: + from_commit_documents.append(Document(file_path, file_content)) + + file_content = _get_file_content_from_commit_diff(repo, to_commit_rev, diff) + if file_content is not None: + to_commit_documents.append(Document(file_path, file_content)) + + return from_commit_documents, to_commit_documents, diff_documents + + +def parse_pre_receive_input() -> str: + """Parse input to pushed branch update details. + + Example input: + old_value new_value refname + ----------------------------------------------- + 0000000000000000000000000000000000000000 9cf90954ef26e7c58284f8ebf7dcd0fcf711152a refs/heads/main + 973a96d3e925b65941f7c47fa16129f1577d499f 0000000000000000000000000000000000000000 refs/heads/feature-branch + 59564ef68745bca38c42fc57a7822efd519a6bd9 3378e52dcfa47fb11ce3a4a520bea5f85d5d0bf3 refs/heads/develop + + :return: First branch update details (input's first line) + """ + # FIXME(MarshalX): this blocks main thread forever if called outside of pre-receive hook + pre_receive_input = sys.stdin.read().strip() + if not pre_receive_input: + raise ValueError( + 'Pre receive input was not found. Make sure that you are using this command only in pre-receive hook' + ) + + # each line represents a branch update request, handle the first one only + # TODO(MichalBor): support case of multiple update branch requests + return pre_receive_input.splitlines()[0] + + +def get_diff_file_path(diff: 'Diff', relative: bool = False) -> Optional[str]: + if relative: + # relative to the repository root + return diff.b_path if diff.b_path else diff.a_path + + if diff.b_blob: + return diff.b_blob.abspath + return diff.a_blob.abspath + + +def get_diff_file_content(diff: 'Diff') -> str: + return diff.diff.decode('UTF-8', errors='replace') + + +def get_pre_commit_modified_documents( + progress_bar: 'BaseProgressBar', + progress_bar_section: 'ProgressBarSection', + repo_path: str, +) -> tuple[list[Document], list[Document], list[Document]]: + git_head_documents = [] + pre_committed_documents = [] + diff_documents = [] + + repo = git_proxy.get_repo(repo_path) + diff_index = repo.index.diff(consts.GIT_HEAD_COMMIT_REV, create_patch=True, R=True) + progress_bar.set_section_length(progress_bar_section, len(diff_index)) + for diff in diff_index: + progress_bar.update(progress_bar_section) + + file_path = get_path_by_os(get_diff_file_path(diff)) + + diff_documents.append( + Document( + path=file_path, + content=get_diff_file_content(diff), + is_git_diff_format=True, + ) + ) + + file_content = _get_file_content_from_commit_diff(repo, consts.GIT_HEAD_COMMIT_REV, diff) + if file_content: + git_head_documents.append(Document(file_path, file_content)) + + if os.path.exists(file_path): + file_content = get_file_content(file_path) + if file_content: + pre_committed_documents.append(Document(file_path, file_content)) + + return git_head_documents, pre_committed_documents, diff_documents + + +def parse_commit_range_sca(commit_range: str, path: str) -> tuple[Optional[str], Optional[str]]: + # FIXME(MarshalX): i truly believe that this function does NOT work as expected + # it does not handle cases like 'A..B' correctly + # i leave it as it for SCA to not break anything + # the more correct approach is implemented for SAST + from_commit_rev = to_commit_rev = None + + for commit in git_proxy.get_repo(path).iter_commits(rev=commit_range): + if not to_commit_rev: + to_commit_rev = commit.hexsha + from_commit_rev = commit.hexsha + + return from_commit_rev, to_commit_rev + + +def parse_commit_range_sast(commit_range: str, path: str) -> tuple[Optional[str], Optional[str]]: + """Parses a git commit range string and returns the full SHAs for the 'from' and 'to' commits. + + Supports: + - 'from..to' + - 'from...to' + - 'commit' (interpreted as 'commit..HEAD') + - '..to' (interpreted as 'HEAD..to') + - 'from..' (interpreted as 'from..HEAD') + """ + repo = git_proxy.get_repo(path) + + if '...' in commit_range: + from_spec, to_spec = commit_range.split('...', 1) + elif '..' in commit_range: + from_spec, to_spec = commit_range.split('..', 1) + else: + # Git commands like 'git diff ' compare against HEAD. + from_spec = commit_range + to_spec = 'HEAD' + + # If a spec is empty (e.g., from '..master'), default it to 'HEAD' + if not from_spec: + from_spec = 'HEAD' + if not to_spec: + to_spec = 'HEAD' + + try: + # Use rev_parse to resolve each specifier to its full commit SHA + from_commit_rev = repo.rev_parse(from_spec).hexsha + to_commit_rev = repo.rev_parse(to_spec).hexsha + return from_commit_rev, to_commit_rev + except git_proxy.get_git_command_error() as e: + logger.warning("Failed to parse commit range '%s'", commit_range, exc_info=e) + return None, None diff --git a/cycode/cli/files_collector/excluder.py b/cycode/cli/files_collector/file_excluder.py similarity index 100% rename from cycode/cli/files_collector/excluder.py rename to cycode/cli/files_collector/file_excluder.py diff --git a/cycode/cli/files_collector/path_documents.py b/cycode/cli/files_collector/path_documents.py index 556a8cf8..73cd0768 100644 --- a/cycode/cli/files_collector/path_documents.py +++ b/cycode/cli/files_collector/path_documents.py @@ -1,7 +1,7 @@ import os from typing import TYPE_CHECKING -from cycode.cli.files_collector.excluder import excluder +from cycode.cli.files_collector.file_excluder import excluder from cycode.cli.files_collector.iac.tf_content_generator import ( generate_tf_content_from_tfplan, generate_tfplan_document_name, diff --git a/cycode/cli/files_collector/repository_documents.py b/cycode/cli/files_collector/repository_documents.py index 379346f8..935d3db1 100644 --- a/cycode/cli/files_collector/repository_documents.py +++ b/cycode/cli/files_collector/repository_documents.py @@ -1,146 +1,26 @@ -import os from collections.abc import Iterator from typing import TYPE_CHECKING, Optional, Union -from cycode.cli import consts -from cycode.cli.files_collector.sca.sca_code_scanner import get_file_content_from_commit_diff -from cycode.cli.models import Document from cycode.cli.utils.git_proxy import git_proxy -from cycode.cli.utils.path_utils import get_file_content, get_path_by_os if TYPE_CHECKING: - from git import Blob, Diff + from git import Blob, Repo from git.objects.base import IndexObjUnion from git.objects.tree import TraversedTreeTup - from cycode.cli.utils.progress_bar import BaseProgressBar, ProgressBarSection - -def should_process_git_object(obj: 'Blob', _: int) -> bool: +def _should_process_git_object(obj: 'Blob', _: int) -> bool: return obj.type == 'blob' and obj.size > 0 def get_git_repository_tree_file_entries( path: str, branch: str ) -> Union[Iterator['IndexObjUnion'], Iterator['TraversedTreeTup']]: - return git_proxy.get_repo(path).tree(branch).traverse(predicate=should_process_git_object) - - -def parse_commit_range(commit_range: str, path: str) -> tuple[str, str]: - from_commit_rev = None - to_commit_rev = None - - for commit in git_proxy.get_repo(path).iter_commits(rev=commit_range): - if not to_commit_rev: - to_commit_rev = commit.hexsha - from_commit_rev = commit.hexsha - - return from_commit_rev, to_commit_rev - - -def get_diff_file_path(file: 'Diff', relative: bool = False) -> Optional[str]: - if relative: - # relative to the repository root - return file.b_path if file.b_path else file.a_path - - if file.b_blob: - return file.b_blob.abspath - return file.a_blob.abspath - - -def get_diff_file_content(file: 'Diff') -> str: - return file.diff.decode('UTF-8', errors='replace') - - -def get_pre_commit_modified_documents( - progress_bar: 'BaseProgressBar', - progress_bar_section: 'ProgressBarSection', - repo_path: str, -) -> tuple[list[Document], list[Document]]: - git_head_documents = [] - pre_committed_documents = [] - - repo = git_proxy.get_repo(repo_path) - diff_index = repo.index.diff(consts.GIT_HEAD_COMMIT_REV, create_patch=True, R=True) - progress_bar.set_section_length(progress_bar_section, len(diff_index)) - for diff in diff_index: - progress_bar.update(progress_bar_section) - - file_path = get_path_by_os(get_diff_file_path(diff)) - file_content = get_file_content_from_commit_diff(repo, consts.GIT_HEAD_COMMIT_REV, diff) - if file_content is not None: - git_head_documents.append(Document(file_path, file_content)) - - if os.path.exists(file_path): - file_content = get_file_content(file_path) - pre_committed_documents.append(Document(file_path, file_content)) - - return git_head_documents, pre_committed_documents - - -def get_commit_range_modified_documents( - progress_bar: 'BaseProgressBar', - progress_bar_section: 'ProgressBarSection', - path: str, - from_commit_rev: str, - to_commit_rev: str, -) -> tuple[list[Document], list[Document]]: - from_commit_documents = [] - to_commit_documents = [] + return git_proxy.get_repo(path).tree(branch).traverse(predicate=_should_process_git_object) - repo = git_proxy.get_repo(path) - diff = repo.commit(from_commit_rev).diff(to_commit_rev) - modified_files_diff = [ - change for change in diff if change.change_type != consts.COMMIT_DIFF_DELETED_FILE_CHANGE_TYPE - ] - progress_bar.set_section_length(progress_bar_section, len(modified_files_diff)) - for blob in modified_files_diff: - progress_bar.update(progress_bar_section) - - file_path = get_path_by_os(get_diff_file_path(blob)) - - file_content = get_file_content_from_commit_diff(repo, from_commit_rev, blob) - if file_content is not None: - from_commit_documents.append(Document(file_path, file_content)) - - file_content = get_file_content_from_commit_diff(repo, to_commit_rev, blob) - if file_content is not None: - to_commit_documents.append(Document(file_path, file_content)) - - return from_commit_documents, to_commit_documents - - -def calculate_pre_receive_commit_range(branch_update_details: str) -> Optional[str]: - end_commit = _get_end_commit_from_branch_update_details(branch_update_details) - - # branch is deleted, no need to perform scan - if end_commit == consts.EMPTY_COMMIT_SHA: - return None - - start_commit = _get_oldest_unupdated_commit_for_branch(end_commit) - - # no new commit to update found - if not start_commit: +def get_file_content_from_commit_path(repo: 'Repo', commit: str, file_path: str) -> Optional[str]: + try: + return repo.git.show(f'{commit}:{file_path}') + except git_proxy.get_git_command_error(): return None - - return f'{start_commit}~1...{end_commit}' - - -def _get_end_commit_from_branch_update_details(update_details: str) -> str: - # update details pattern: - _, end_commit, _ = update_details.split() - return end_commit - - -def _get_oldest_unupdated_commit_for_branch(commit: str) -> Optional[str]: - # get a list of commits by chronological order that are not in the remote repository yet - # more info about rev-list command: https://git-scm.com/docs/git-rev-list - repo = git_proxy.get_repo(os.getcwd()) - not_updated_commits = repo.git.rev_list(commit, '--topo-order', '--reverse', '--not', '--all') - - commits = not_updated_commits.splitlines() - if not commits: - return None - - return commits[0] diff --git a/cycode/cli/files_collector/sca/sca_code_scanner.py b/cycode/cli/files_collector/sca/sca_file_collector.py similarity index 70% rename from cycode/cli/files_collector/sca/sca_code_scanner.py rename to cycode/cli/files_collector/sca/sca_file_collector.py index febd8858..e3ed22f8 100644 --- a/cycode/cli/files_collector/sca/sca_code_scanner.py +++ b/cycode/cli/files_collector/sca/sca_file_collector.py @@ -3,6 +3,7 @@ import typer from cycode.cli import consts +from cycode.cli.files_collector.repository_documents import get_file_content_from_commit_path from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies from cycode.cli.files_collector.sca.go.restore_go_dependencies import RestoreGoDependencies from cycode.cli.files_collector.sca.maven.restore_gradle_dependencies import RestoreGradleDependencies @@ -17,15 +18,30 @@ from cycode.logger import get_logger if TYPE_CHECKING: - from git import Diff, Repo + from git import Repo BUILD_DEP_TREE_TIMEOUT = 180 -logger = get_logger('SCA Code Scanner') +logger = get_logger('SCA File Collector') -def perform_pre_commit_range_scan_actions( +def _add_ecosystem_related_files_if_exists( + documents: list[Document], repo: Optional['Repo'] = None, commit_rev: Optional[str] = None +) -> None: + documents_to_add: list[Document] = [] + for doc in documents: + ecosystem = _get_project_file_ecosystem(doc) + if ecosystem is None: + logger.debug('Failed to resolve project file ecosystem: %s', doc.path) + continue + + documents_to_add.extend(_get_doc_ecosystem_related_project_files(doc, documents, ecosystem, commit_rev, repo)) + + documents.extend(documents_to_add) + + +def perform_sca_pre_commit_range_scan_actions( path: str, from_commit_documents: list[Document], from_commit_rev: str, @@ -33,40 +49,25 @@ def perform_pre_commit_range_scan_actions( to_commit_rev: str, ) -> None: repo = git_proxy.get_repo(path) - add_ecosystem_related_files_if_exists(from_commit_documents, repo, from_commit_rev) - add_ecosystem_related_files_if_exists(to_commit_documents, repo, to_commit_rev) + _add_ecosystem_related_files_if_exists(from_commit_documents, repo, from_commit_rev) + _add_ecosystem_related_files_if_exists(to_commit_documents, repo, to_commit_rev) -def perform_pre_hook_range_scan_actions( +def perform_sca_pre_hook_range_scan_actions( repo_path: str, git_head_documents: list[Document], pre_committed_documents: list[Document] ) -> None: repo = git_proxy.get_repo(repo_path) - add_ecosystem_related_files_if_exists(git_head_documents, repo, consts.GIT_HEAD_COMMIT_REV) - add_ecosystem_related_files_if_exists(pre_committed_documents) - - -def add_ecosystem_related_files_if_exists( - documents: list[Document], repo: Optional['Repo'] = None, commit_rev: Optional[str] = None -) -> None: - documents_to_add: list[Document] = [] - for doc in documents: - ecosystem = get_project_file_ecosystem(doc) - if ecosystem is None: - logger.debug('Failed to resolve project file ecosystem: %s', doc.path) - continue + _add_ecosystem_related_files_if_exists(git_head_documents, repo, consts.GIT_HEAD_COMMIT_REV) + _add_ecosystem_related_files_if_exists(pre_committed_documents) - documents_to_add.extend(get_doc_ecosystem_related_project_files(doc, documents, ecosystem, commit_rev, repo)) - - documents.extend(documents_to_add) - -def get_doc_ecosystem_related_project_files( +def _get_doc_ecosystem_related_project_files( doc: Document, documents: list[Document], ecosystem: str, commit_rev: Optional[str], repo: Optional['Repo'] ) -> list[Document]: documents_to_add: list[Document] = [] for ecosystem_project_file in consts.PROJECT_FILES_BY_ECOSYSTEM_MAP.get(ecosystem): file_to_search = join_paths(get_file_dir(doc.path), ecosystem_project_file) - if not is_project_file_exists_in_documents(documents, file_to_search): + if not _is_project_file_exists_in_documents(documents, file_to_search): if repo: file_content = get_file_content_from_commit_path(repo, commit_rev, file_to_search) else: @@ -78,11 +79,11 @@ def get_doc_ecosystem_related_project_files( return documents_to_add -def is_project_file_exists_in_documents(documents: list[Document], file: str) -> bool: +def _is_project_file_exists_in_documents(documents: list[Document], file: str) -> bool: return any(doc for doc in documents if file == doc.path) -def get_project_file_ecosystem(document: Document) -> Optional[str]: +def _get_project_file_ecosystem(document: Document) -> Optional[str]: for ecosystem, project_files in consts.PROJECT_FILES_BY_ECOSYSTEM_MAP.items(): for project_file in project_files: if document.path.endswith(project_file): @@ -90,7 +91,11 @@ def get_project_file_ecosystem(document: Document) -> Optional[str]: return None -def try_restore_dependencies( +def _get_manifest_file_path(document: Document, is_monitor_action: bool, project_path: str) -> str: + return join_paths(project_path, document.path) if is_monitor_action else document.path + + +def _try_restore_dependencies( ctx: typer.Context, restore_dependencies: 'BaseRestoreDependencies', document: Document, @@ -110,34 +115,13 @@ def try_restore_dependencies( is_monitor_action = ctx.obj.get('monitor', False) project_path = get_path_from_context(ctx) - manifest_file_path = get_manifest_file_path(document, is_monitor_action, project_path) + manifest_file_path = _get_manifest_file_path(document, is_monitor_action, project_path) logger.debug('Succeeded to generate dependencies tree on path: %s', manifest_file_path) return restore_dependencies_document -def add_dependencies_tree_document( - ctx: typer.Context, documents_to_scan: list[Document], is_git_diff: bool = False -) -> None: - documents_to_add: dict[str, Document] = {document.path: document for document in documents_to_scan} - restore_dependencies_list = restore_handlers(ctx, is_git_diff) - - for restore_dependencies in restore_dependencies_list: - for document in documents_to_scan: - restore_dependencies_document = try_restore_dependencies(ctx, restore_dependencies, document) - if restore_dependencies_document is None: - continue - - if restore_dependencies_document.path in documents_to_add: - logger.debug('Duplicate document on restore for path: %s', restore_dependencies_document.path) - else: - documents_to_add[restore_dependencies_document.path] = restore_dependencies_document - - # mutate original list using slice assignment - documents_to_scan[:] = list(documents_to_add.values()) - - -def restore_handlers(ctx: typer.Context, is_git_diff: bool) -> list[BaseRestoreDependencies]: +def _get_restore_handlers(ctx: typer.Context, is_git_diff: bool) -> list[BaseRestoreDependencies]: return [ RestoreGradleDependencies(ctx, is_git_diff, BUILD_DEP_TREE_TIMEOUT), RestoreMavenDependencies(ctx, is_git_diff, BUILD_DEP_TREE_TIMEOUT), @@ -149,28 +133,38 @@ def restore_handlers(ctx: typer.Context, is_git_diff: bool) -> list[BaseRestoreD ] -def get_manifest_file_path(document: Document, is_monitor_action: bool, project_path: str) -> str: - return join_paths(project_path, document.path) if is_monitor_action else document.path +def _add_dependencies_tree_documents( + ctx: typer.Context, documents_to_scan: list[Document], is_git_diff: bool = False +) -> None: + logger.debug( + 'Adding dependencies tree documents, %s', + {'documents_count': len(documents_to_scan), 'is_git_diff': is_git_diff}, + ) + documents_to_add: dict[str, Document] = {document.path: document for document in documents_to_scan} + restore_dependencies_list = _get_restore_handlers(ctx, is_git_diff) -def get_file_content_from_commit_path(repo: 'Repo', commit: str, file_path: str) -> Optional[str]: - try: - return repo.git.show(f'{commit}:{file_path}') - except git_proxy.get_git_command_error(): - return None + for restore_dependencies in restore_dependencies_list: + for document in documents_to_scan: + restore_dependencies_document = _try_restore_dependencies(ctx, restore_dependencies, document) + if restore_dependencies_document is None: + continue + if restore_dependencies_document.path in documents_to_add: + logger.debug('Duplicate document on restore for path: %s', restore_dependencies_document.path) + else: + logger.debug('Adding dependencies tree document, %s', restore_dependencies_document.path) + documents_to_add[restore_dependencies_document.path] = restore_dependencies_document -def get_file_content_from_commit_diff(repo: 'Repo', commit: str, diff: 'Diff') -> Optional[str]: - from cycode.cli.files_collector.repository_documents import get_diff_file_path + logger.debug('Finished adding dependencies tree documents, %s', {'documents_count': len(documents_to_add)}) - file_path = get_diff_file_path(diff, relative=True) - return get_file_content_from_commit_path(repo, commit, file_path) + # mutate original list using slice assignment + documents_to_scan[:] = list(documents_to_add.values()) -def perform_pre_scan_documents_actions( +def add_sca_dependencies_tree_documents_if_needed( ctx: typer.Context, scan_type: str, documents_to_scan: list[Document], is_git_diff: bool = False ) -> None: no_restore = ctx.params.get('no-restore', False) if scan_type == consts.SCA_SCAN_TYPE and not no_restore: - logger.debug('Perform pre-scan document add_dependencies_tree_document action') - add_dependencies_tree_document(ctx, documents_to_scan, is_git_diff) + _add_dependencies_tree_documents(ctx, documents_to_scan, is_git_diff) diff --git a/cycode/cli/files_collector/zip_documents.py b/cycode/cli/files_collector/zip_documents.py index 770121fa..6f5edd81 100644 --- a/cycode/cli/files_collector/zip_documents.py +++ b/cycode/cli/files_collector/zip_documents.py @@ -27,7 +27,7 @@ def zip_documents(scan_type: str, documents: list[Document], zip_file: Optional[ _validate_zip_file_size(scan_type, zip_file.size) logger.debug( - 'Adding file, %s', + 'Adding file to ZIP, %s', {'index': index, 'filename': document.path, 'unique_id': document.unique_id}, ) zip_file.append(document.path, document.unique_id, document.content) @@ -37,13 +37,13 @@ def zip_documents(scan_type: str, documents: list[Document], zip_file: Optional[ end_zip_creation_time = timeit.default_timer() zip_creation_time = int(end_zip_creation_time - start_zip_creation_time) logger.debug( - 'Finished to create file, %s', + 'Finished to create ZIP file, %s', {'zip_creation_time': zip_creation_time, 'zip_size': zip_file.size, 'documents_count': len(documents)}, ) if zip_file.configuration_manager.get_debug_flag(): zip_file_path = Path.joinpath(Path.cwd(), f'{scan_type}_scan_{end_zip_creation_time}.zip') - logger.debug('Writing file to disk, %s', {'zip_file_path': zip_file_path}) + logger.debug('Writing ZIP file to disk, %s', {'zip_file_path': zip_file_path}) zip_file.write_on_disk(zip_file_path) return zip_file diff --git a/cycode/cli/printers/tables/sca_table_printer.py b/cycode/cli/printers/tables/sca_table_printer.py index 1bf358c8..c0bedcc7 100644 --- a/cycode/cli/printers/tables/sca_table_printer.py +++ b/cycode/cli/printers/tables/sca_table_printer.py @@ -63,7 +63,7 @@ def _get_table(self, policy_id: str) -> Table: elif policy_id == LICENSE_COMPLIANCE_POLICY_ID: table.add_column(LICENSE_COLUMN) - if is_git_diff_based_scan(self.scan_type, self.command_scan_type): + if is_git_diff_based_scan(self.command_scan_type): table.add_column(REPOSITORY_COLUMN) table.add_column(SEVERITY_COLUMN) diff --git a/cycode/cli/printers/tables/table_printer.py b/cycode/cli/printers/tables/table_printer.py index 6fc85a1b..6a5dd198 100644 --- a/cycode/cli/printers/tables/table_printer.py +++ b/cycode/cli/printers/tables/table_printer.py @@ -48,7 +48,7 @@ def _get_table(self) -> Table: table.add_column(LINE_NUMBER_COLUMN) table.add_column(COLUMN_NUMBER_COLUMN) - if is_git_diff_based_scan(self.scan_type, self.command_scan_type): + if is_git_diff_based_scan(self.command_scan_type): table.add_column(COMMIT_SHA_COLUMN) if self.scan_type == SECRET_SCAN_TYPE: diff --git a/cycode/cli/printers/utils/__init__.py b/cycode/cli/printers/utils/__init__.py index e1676c35..d1ee86c0 100644 --- a/cycode/cli/printers/utils/__init__.py +++ b/cycode/cli/printers/utils/__init__.py @@ -1,8 +1,5 @@ from cycode.cli import consts -def is_git_diff_based_scan(scan_type: str, command_scan_type: str) -> bool: - return ( - command_scan_type in consts.COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES - and scan_type in consts.COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES - ) +def is_git_diff_based_scan(command_scan_type: str) -> bool: + return command_scan_type in consts.COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES diff --git a/cycode/cli/printers/utils/code_snippet_syntax.py b/cycode/cli/printers/utils/code_snippet_syntax.py index 12501544..d9ea3af2 100644 --- a/cycode/cli/printers/utils/code_snippet_syntax.py +++ b/cycode/cli/printers/utils/code_snippet_syntax.py @@ -108,7 +108,7 @@ def get_code_snippet_syntax( lines_to_display_after: int = 1, obfuscate: bool = True, ) -> Syntax: - if is_git_diff_based_scan(scan_type, command_scan_type): + if is_git_diff_based_scan(command_scan_type): # it will return syntax with just one line return _get_code_snippet_syntax_from_git_diff(scan_type, detection, document, obfuscate) diff --git a/cycode/cli/utils/path_utils.py b/cycode/cli/utils/path_utils.py index 7d525e56..ce60b0da 100644 --- a/cycode/cli/utils/path_utils.py +++ b/cycode/cli/utils/path_utils.py @@ -111,3 +111,11 @@ def get_path_from_context(ctx: typer.Context) -> Optional[str]: if path is None and 'paths' in ctx.params: path = ctx.params['paths'][0] return path + + +def normalize_file_path(path: str) -> str: + if path.startswith('/'): + return path[1:] + if path.startswith('./'): + return path[2:] + return path diff --git a/cycode/cli/utils/scan_utils.py b/cycode/cli/utils/scan_utils.py index 8c9dcca7..57586b51 100644 --- a/cycode/cli/utils/scan_utils.py +++ b/cycode/cli/utils/scan_utils.py @@ -1,11 +1,29 @@ +import os +from typing import TYPE_CHECKING +from uuid import UUID, uuid4 + import typer +if TYPE_CHECKING: + from cycode.cli.models import LocalScanResult + def set_issue_detected(ctx: typer.Context, issue_detected: bool) -> None: ctx.obj['issue_detected'] = issue_detected +def set_issue_detected_by_scan_results(ctx: typer.Context, scan_results: list['LocalScanResult']) -> None: + set_issue_detected(ctx, any(scan_result.issue_detected for scan_result in scan_results)) + + def is_scan_failed(ctx: typer.Context) -> bool: did_fail = ctx.obj.get('did_fail') issue_detected = ctx.obj.get('issue_detected') return did_fail or issue_detected + + +def generate_unique_scan_id() -> UUID: + if 'PYTEST_TEST_UNIQUE_ID' in os.environ: + return UUID(os.environ['PYTEST_TEST_UNIQUE_ID']) + + return uuid4() diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index b1c697c6..6ddce8d5 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -92,6 +92,15 @@ def zipped_file_scan_sync( ) return models.ScanResultsSyncFlowSchema().load(response.json()) + @staticmethod + def _create_compression_manifest_string(zip_file: InMemoryZip) -> str: + return json.dumps( + { + 'file_count_by_extension': zip_file.extension_statistics, + 'file_count': zip_file.files_count, + } + ) + def zipped_file_scan_async( self, zip_file: InMemoryZip, @@ -102,24 +111,19 @@ def zipped_file_scan_async( ) -> models.ScanInitializationResponse: files = {'file': ('multiple_files_scan.zip', zip_file.read())} - compression_manifest = { - 'file_count_by_extension': zip_file.extension_statistics, - 'file_count': zip_file.files_count, - } - response = self.scan_cycode_client.post( url_path=self.get_zipped_file_scan_async_url_path(scan_type), data={ 'is_git_diff': is_git_diff, 'scan_parameters': json.dumps(scan_parameters), 'is_commit_range': is_commit_range, - 'compression_manifest': json.dumps(compression_manifest), + 'compression_manifest': self._create_compression_manifest_string(zip_file), }, files=files, ) return models.ScanInitializationResponseSchema().load(response.json()) - def multiple_zipped_file_scan_async( + def commit_range_scan_async( self, from_commit_zip_file: InMemoryZip, to_commit_zip_file: InMemoryZip, @@ -127,6 +131,20 @@ def multiple_zipped_file_scan_async( scan_parameters: dict, is_git_diff: bool = False, ) -> models.ScanInitializationResponse: + """Commit range scan. + Used by SCA and SAST scans. + + For SCA: + - from_commit_zip_file is file content + - to_commit_zip_file is file content + + For SAST: + - from_commit_zip_file is file content + - to_commit_zip_file is diff content + + Note: + Compression manifest is supported only for SAST scans. + """ url_path = f'{self.get_scan_service_url_path(scan_type)}/{scan_type}/repository/commit-range' files = { 'file_from_commit': ('multiple_files_scan.zip', from_commit_zip_file.read()), @@ -134,7 +152,11 @@ def multiple_zipped_file_scan_async( } response = self.scan_cycode_client.post( url_path=url_path, - data={'is_git_diff': is_git_diff, 'scan_parameters': json.dumps(scan_parameters)}, + data={ + 'is_git_diff': is_git_diff, + 'scan_parameters': json.dumps(scan_parameters), + 'compression_manifest': self._create_compression_manifest_string(from_commit_zip_file), + }, files=files, ) return models.ScanInitializationResponseSchema().load(response.json()) diff --git a/images/sca_report_url.png b/images/sca_report_url.png index f438180ec80aa9f8f44c6588b15d7256467fcf42..6513966ef2987b99ae3baef5b0f940cf1a5a9e71 100644 GIT binary patch literal 243756 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 z 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ⅅ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
Fv zW2R=uP|$JwUQA0hUsP^JNC2aeeEK8aC1t7K95B z@*sY;=k}Otth1S(xU3r1JnGlYQ@5jyqxoi4#5;d^R61Mkqq77BsqAw&S5o+5)=crl z=@Btttig4~IR!Y2EV}mOERzkF0_9KK?slHM^=dkIA|sprANDQAu;x*g+~#h&9F~9un$pMP! zU{>hBKEajKcn+?8<*i4~_Ed5QB&aw?#b>W+%2na^Sc8_9_)0upBvHyVvP?w?^&lNI5}K}@4AigJk8uHp}_ z{obOqN5`|;`lp`N^+1h=hKaGcl%@7MMp;NwQua8W8ZJ_;-87LD)$>Py5w??w>^&!K zA`}8sV_5`i`Z6Cxv^ov@=!ceG9m2C2RDh9^h0n?6RHJJXIl16pO$Cr}6+HtTrK zPZABKvpTi<) zDR+^?emK^(VV(nfy_IlI=G?Y>nmt~!JjVf8Cbr4#apj^(G3AWHY~OlrP8UG^r8J$= zW=_xLkQaO|mKmx{UV)C^fE*hd%h@45@l$Mcd?kUCP1y6TT66s|k;2qtA`EN;wLI8l zEBK{s>Blyk^3^$)5v&0C9!k=ggFk@()x@gSFdUeu1PFeBXn-0v*HGb0-+<)nq8;#| zEasF@Gabxjs%g8V~8XD=GUfm{~I| zPK%!W#dz*5{TfUk#qxD(zk-aOw`x_fzm5haC#$BU70rG7@G{wrUSbT{=mdL2M--}O zcgW~N?}z;l1Scz5RKzImonSeboz~a9B3yN@->Z9Sv=T<+l#eRT6p``s(wtA{L7%G49HI5~1BwrIa`d|vI*a5|SY}IQ&$|;o}?DXYU#_oQq9IMF9 zNRp-C{W|g~XXDL6N;1Y#b4D}}zrIl$X3`*Q3mo0VS3p9c}{?{QJH zZ$F1RW;lGN@5~6fQSq~?S$K^;F{Qh*CW#`4Dkl@p=^zhCINR#D_I&J_vOByVLkHR1 zATaZAHc<#W?9JAXz&2!wMGcxNq)kWM?b5-lUBDwnrBt62TV#q$%Fowy5xSZp9W^gj zQh^}c*mpQi{(i4&()VZV6{=c~@MgfY{t1<9y8);EW8o@+(RsElf2bZRIOa<1!e85z zU9_ggEXEpkgxaDZ8`)a|HyHj!;L7@X05Y^>+z;(`uG+Y`CN|8oV!s~J40lQ{zE-tm zjjmMT5Cp!#@4$`mSa>^GV(o*Rl>$TK>-uvi4D&Y#tn&mhtJkev0mGJm%U!8Tu25~i z4j!HvNpT<|*opp%^mD2^4a=fvu4spspm@o7yvRz6cXLeh`;B_DBX4{S>dea4a}*-0 zfFNw(e`$Nvgn67HC;Ez;`Z(uU{$pN-g-|6^i8 zcA@6-ql0@?doWh6Ra&3MQp*-84KY?v*WWb{?*bs0>qCOk|5gQnZ~ zmmESwTF&6a@qxHT*h~kWPT$5O>fplIt?XL|uchyKCm%iqU~zY`U@!t|ES$7-)^zsw zVu*=-{t06r>)O#y^P0h5<<>{v<x$+4ME8uV`fcL1zf}C3>&CkxnwEv8 zNAIsZ(mF2TZ}uE~%08h#LwBpm7O_6!2-!N6QvDmpKN&5sAr@0~`#Mu%icjnb^Q`lC zC{M;{<##ezu?}yaXQ({B4_rb=F%YDlym2C4`%qI2N92Y7KsWkn2l{XRohQvO^k{#z zIvqhZ$}+lY4u7Wh{5s%RkSBXNzNq2?f%8z=8!yNqNc-@B@z(m0H<9ztmH~v*nM8%9 z1SJ-z;fHF2BVMZZQ2UWmEC0<8^SNPdk0kXf zYuW-h7pv?BB6oU-d0DAvMD6_hNMS>Br+FRo2Qr(nx8wbET?5+MeiG(QrLf4)QQeH| zO|+;N@1e*ut5yonLK8TImJ`eE!-wegSRxR)v6B#2I!Xw+SM<#88D2?Y7sc#B%gDoJ zvYOiCx>xiRMNwiJ@41mK<597_sx#pn5+BJSJh= zaOrDQd~?_i%B#E!$e_Y9owFPf^i|IR8pj}c^P#{ZD&VW7LM*}Ad`D{`LuIaoM?IYG zTw<@5m4FeO$rEUci4G40<~)FWpi7v=N+kTFcd`@CbXqUJm#urrIht_8l1h~(#5?d> zt5BL)SXyKm5eWH_9wmgv1*^1h~_h{#`IsTwHGq=jBo-?Trf z%Ul;O4P3jEJ*{!81Sf3jP{GW(g`vn-FZaV27zGnI4OJD8eXMHd8MxRK&an0DlR!j$ znhz(%V;o6UUxc`jG=Dex2hH`r(0xV?L2D7LFr3s2z*#Jc1YkwuD+%URnv7E?C=_NMgdLkzLIH&Z}N9A*{J0S2(yXof>P!HJ73c z#_7fkXOcL0vKL7Qd+LW*G3Zf?Cc5@BSofiOR9-aF$K6^N+qf3wS!hKH_L(XDneiEf z;vio86?UB@a^ZAx1f>#nzd7>Pw|eBX$;0>1`CH?xdHRSqpJO@WGjev2a=(RzFIt8& z?_adU`4{HX_=-5|8N)w2*F*!DnumH8v&1>+|`^mQfd>O!9poj z#?OR_&~C+bOQwy|E9}`)F@U=+W!>w9(EfJsjn-1`0J6A-j}6-w0i5tLu9L=O26XkXIU(x$(gPmvCI-J=hCLYv8C1i zWAQNNmkaOd#>zWdO`_y}Tg!_SRkak_QR2_lJ5D-62LrXJ5sbCci&q#>>+UP6SYC=8 zyXn*`G7Z-=3i(cJR}}AoWVRhaeEhM=ngpdEvJBDp=jd>o6^l1hdDm|R{#k%!RjDt7 zqO7rPWmfkIRmN-x{6osPrq;FM4_*Mki|x<~}l_T+|y6 zku!D*8=6=|@%D5@!LF*j&-zDYoz0;Xz;I#12=wiVTF?1>Z9Aw}7}K7H^Fx*Z>VIiC z2hEX@SofQ-iV*A!wZ37_1GSr32Uqw7cAoUbjo$P*$x;r6sw_BB@4}Cic0-^rPWwy1 zSY@>~P%Rp4heScB@{-_A;j@hk>Y|9(?Z&)BckKHkDUif%*Og(Bc~Ft*N_MubmNUfg zLYtC~Myd>W(Wt)nrQJDHM80Hu{9WS5m2*2*v!!pf4z6A-U{DSxG}%g=q>5(2Wfl#b z#4?6|-YID`Vecwcs+!`c7I>&0T-oSyVW|zaVSJ9d?ku8^Lip3=e;5w3+CQ`qV_6S`FwJEU@t2+e1N+EOvvv zZXb`)xaME?b9UpAtY`T-NNA;!67up(g|kl6mhc0PE~pRK-2*btJ1ktxc4h z)s>4F2yJv|b=tnX6gntB{c^I!${zO>LJ*U*`hYF20djf7>_$ekb&J?WbIT7{6+ueIDZB(Nq{*_Ta~pQe}1HAZ4Db+^0y+qJ!p+{F?FbD(I7f zX^x`io-vfU@@AD;+#h5Y>kP=q1$&)m*!g*TXzey9Ki*Rt`6tZ}qLV3_$N=`vRC=bG z{GZ`is~)LOxE z7B`aJyMVQjw4tJsP`^qeqem)H-3A*&xHDZ*PSLO9Y zqSw@z7r&Y+QLo_tXtn*7&d_d?5D=rv&Ea7JCv}vQS=jd5tT`YCiGCU6tYtsMs?-yk z5SCqKLs_{ebf#Vu>=b7K#!jQ~7%iU897Qd#{<=NRPg}>5+az;eUK8u{&vW9uZLELf z_uCYX)a*vN=a_T{4CEv}LU=AezW(N1?B{zU%zaQE%iZw&AAq8v@qLrmo2;3)fjs;{ zKe(7}{!AurkJ%}9DAjkrqKR?j%T5Bh2=X?6ADZ--(j!^S($td*`?9Hk<>;{k&)nqC z_;%X&bX)RzqJb%5=(!RC;&78@bJ(o`68ub5u~)zQ>AO2m-9K{3-nsoz`sCuxt6!x5 zd3V!URGAl+&!z`@)#uL@_ssR#%?v%4)E!T7xXGZYvc|n!TrRE&x74!LOu!xI_BL;Y z^uY5;>)_Bfw~B)aH%89oGP>#B1A3KEFXwA)jE-r!i!wC%iQ$|IJ^S|j$ydmo5unsE zaGyY7FuoVjk=A)(RTUeK?^HW)?jaRU>NxTQIU^{l2}(*CmK1C`n`VLmI+RPBPyE7G z{u=i@v|2CPX{KiDsMubE}N#Xn%E#I&3kZ5rI#7hNz+DW;Z<`#E_^2} z3U=%PB@D4kjMG{riDoSV9k={flfOLV7 zASy*5fFMOmL=aSZ?*s)&2qhrBM(I5eY7&xsoAcfAj(6Pq4dzkr1jHlD_@*wlnd zrW&TkwS{v?eHHlRIl|Y64kOJk_m*roQ)S9sKVmr&TC_{}+D8>Zpp~mnE|vkg%NYAl z@Skk6+phc?a0ou_cbDgCXD`_|Zd3TjY5&wskz66#Z-nsZMS--%O7X4IknSJzYC%fd zPF<$iP8g&=56Yi2h>EYYy z6y=MqmrSo&(e1z41Cb<~Ptg?~E-UB3Lzf&-=S5cQf=TqLoHzjB%D>AW@+odR-OWHj zqrY_LWP^p4Rr<45xKu@Sp-y?oM-{yaw?%Fr<@X<&-&M&_#jQr0H{t$w_WSU8UUx|r zbV9Cv+^uTw6}FY@L1D-nXYgsrh~;zTzkibX`P*F#(|v6+E1g!p@}0EF_iP^|Hk1`r z*@*7lDIQf!_%<56u*2SvhIGk)&*u_Bh+uYaWbUmQ_qQHQy{-^YcO2q43JxX>!@$e!B2ojqy?Scs)wy9Q1(CSDadz*A26-=}NIWhS171>(RW`M{6AIr$C?VuJfA0Uh!_2QYb!5x}wj_DtTe3O!1V!B4rF}>L2q|{v0k6 zvZaE#%vVgl#$%5A{XHQ95&K-ZrJ`_&?!{_`FHpBI9ahiE~koBw@loT&zbI$u5Ot6 zl40dHYiJteNc|;3vba{byA#=Fwh-a=9j&$Ok)C4LNFA%nbArXywyt$P@<|`i(oALt zR$r9xMpb_-B0$yX6o75%_(gu)B;3fZns+^}s`Ex969f{`Og^_zYMxmKjiHrHP|k zOWmF98bUA-l|F+3(vciRr5tV@5#bnROkGnV{&`c$T~i@wbisf-FrZWlN!g*UgJ0M+ z^tNl5tMA3vwsVH!5=In@66EA|U$u7kG&QE)SiG;+!}sGN?+@`;5>B%;sf1G+y#sNH z%@@l8(JcnSsF~{ksn#d#il_|UwtSSE*ZIZ#_{H(}BmCm!r?dLSzFqNum)w(aB*MEyc^3TArBU6e-$fvJoR5 z>ijuXTsSOg<}P00cOKJum6}U3U%HDx{J<`H@JZoT_%W(>CLaOBHq+J*#dNMKW1P*(F?aPEliO~a zb7r63me+WCcK;(_b{g}p?%>z2r-h%!N%L{{8tG6p;< zjE~+!>vHNF428x}4dA{HovU7R2i?w7;1!=y%_2{Qb?H6WTGt5vRMvS}R9gL&1om9y z))d}cUhib3Leu!M>}}Zx`u>elPQUik0T50uQO|!Xhk44z*?K8O+`g|$FCMS^QHAeG z(=olD;wfy8xz|?XQ(d>Y*a!DvH}#fPN#lQuipNe^vVZG2ugrRhHIrRRj31Q{f16ka z7>4{^@h5YT+`eJCdTd$80M)r2Tz$2&W;f1jS;vQ;LtSPG#Q$ew-9T)W{Y`^-Mu0E; zQtF4c@K52|XGT>Q>k%0T(t_!Q@>KN)KlJvZsiq+ur&`5wSHkS!Q%czVaqYK8J>l74 z0e@Zoq#`kztTNc@p~Y#5vcAOC81dHf)B3RfidDP%lvsUF`5HO6;?*d5Wk1e%I}WSX zIENkr&W}@A4vyI6YPu89UomR-ZZ*e2i;aGpnrb(yN|OrBT@7+}@VF1D0cYwsvyZ;)HH5oG0CoW2=pH#Jt0s!Gd|eBhr0zCRyDwOR z9}~kiF>Bt8tPbU3OQ^8QY+Iu+gpKtcmrd_ywc9uM z$SLRIp4D%vPH_G=Hi7Ha_ z;E>8W59@Jq!>NQI?ua2J4BsJrm>YQl$o5FDB!1>(1f}2OEqSvePQ5Q&S+{N!S4}j#6Kp z*Aa=vydu47N)6;kTsZL8-;~-kfbjSyj#T^6)ai9OIWzSZ7o@8dY*yMpf1ONi80vi> z^1p;RCx6vgsWQ+Q2L}#_pRFzD9qwAQZrCr>5FF2N;*47BJXQBM$8<@$nuFIDP2z5L z7RNdZo)$dyz=(tTBg|Y;R~Ms_tOQE4;TSbOBh(&ti@B3wA?zu{+A>cs99?<`Y+$oyn~98*%_j)|mMokh`U^UgZa29-kh0EH<-#gHMljdO#}^gm7)1jqS1PAat_eh(>xeeD-FfJdH} z=idr9fEyqbZ#s7Ns`;J(tuoYoqzazGaN%LVHPpTj7iE96WxN$yoHYs)MBG$8LH7^^ zWsORi6rg=ja&}EX5rpmR-*4oLQ)pv-D5k~cftO_bl#yxILBVcN}Lw}zn_ilaj0p4rFSQ{W*DzOPB*j_soD{#>2nn{ zbZ=f+mG$lPYj2=tnuuN~CtSvN@b!VBr8MfE-~Q=H+gy{PEcr;Qw! zEB5^o>rcBA{5ZcFYYD_lFz|v+B4ijbMvC%;BP!PYLMinXNUwQOZ`Vo&#C2T$V0+cH z`SOY;GPd#d9FJFtm$bT*6n#1rI{7i^usdg@y?Y->m!0}c;$;MlF4~Z<1b^9YQ5Ww% z?-r-A(Ly2nc_5x;rjTn_isyu?niAYJlX;URquja69p6LW5DZ67l@$<3GX+I=na=zT zz~$me<3mK#eCe`2z3g$szPXi13+W)TU4xV}Vv>PSk{>z-d{^PS`zJImpL1byBVf-Esu(p7kEIbd5!y(OGk0JwVsvCQB}sxqJ6J2wGL`X=*0(2!)3*<>VIQ8vV4Ersh13B2gxhf9hzI`yPETxI3Y@ zN2@NWNKJkY*HFl8c-Y+dzRkZ!pa8|1dyGs89uy9~rjNgspHjY7w0r`!@&OefIDS(+ z$?Kp!Ax&7+C@|lwG**gRs^vZV-3$()K7qFC;F^op7Ljd^&Vo_yi%e1Ww3v%WikVb{ z4xtR;$ZIqHN+LHk&cFYgW^l}(L+W?i{ZGfhvROwS=*Eu$=}SiwogkV?J~@6yp)?7{ zS(|_i1eK zoTkc^z72K6@&CASMBKGUkykz1r!-ebP(`2rn(KFVBJq{r-WlC3Ep#a2->@Tand4y; zFKekVQ<){NBk$|P9iH0p6o%faq`~4%k7&3;UQZxHH`@2z*ipg~DKq|k@7FQ}r1b4o zrbuK-gR?Niym)T2G6Vi4?9764+y_UMgs=4{qYvTaW!@spUIbhfeY8nQY zBD>Nqq>Y3+4F|MKfJn#s@E1n2qaSnIy1tjel`ZrGF^?$=;;QdcT{#VnpH$0{+CPfJ z<0sU6w_ct;c_SBJ-(sy7QPuV&6)?FQ-_6Lp65Bpdzc#P~7Y%22-!w^MCiNkeiIpbE za!LI4e_;K5X>E6StLzpP>Q%s897?;KBEx5*jInPgs<|}?KgvVJZ)9N|@b?Lp>g(qf zURe{x2;1{aYHdref*$3TVmEFwuv#wIq4RvO66#mqsQmq^9H$T*zyWWbM%v632-~QWhEo^OW@h6^HI7u{s<$*@H<>*3$|#dSyN>-{nWwd7hspu zW>*`BUd+Ewma7^lt2Tfuf0^sHTz%+cGlCOEN4~Sq%CT$pzwY_MWXxBfcdD*&=N0KD zW?b4ueoRc3V3NDH)iZKinn;xnYG?tgwj+c z0^C@1XrhxH6%C0N0X*Ip;c)A621u75G zlwBziiBt&OT_~^#-}hM>Y*qUH`WOLKQp_n9hdek zj|U4?A{vC6<;XtQbobjZZa|Fv{^@84=jsv&Cu!??yze7dd^-D-}HY+-R8)<4eOfMque2>80tH*?N3~>h}P`^b?CHo zj6tZMZQ#n0g#+U%xbduZI0@K0*Lmse~mo$ zPQFQo-C9Abr_YM_uVYX31LW-FfGFE$_2y$rnr`9ns|JGh6*dhX{IUX=bEH338-MvI zHW|6U`}&li$w#8r^qdUfK4)njHlgmp(e=}i+NA!1GVjMttY?0GWsc})arV{yHg~9j zHWO0cLEE#>qb3@YeB!JE8Sg8W$=Esy!~hB*_5oxye}uH$`WKiW%>14#eB z%TN>NKjK0yx86L0pj+R;EesM3v?AXX^CUmTW-LX%4vRSj5|Da&Uiw;y6!$G8s6P>x zZ(pVx@0Ay#(O1!T`FxRW8a}s?vl)JQ`{?_3yPFO8Hr3-(DLGj__gT+i6yaCBU$H^Q z?;Rhy&DhCb;CUk)mlanzO*PN~Vm+ss+fp~;jUv5J;w7^w%^OxTrty%BuYmQg&YOhP ze@>CxQGra?CX0J1O*l3BWDtUGh_%P!gxL1mW$PAlYwpG$1alE<#C4}PAz5JD4-P{) z`=irz(R&@b+rNj#lxEpE7Vs4Sn#RleEW;GJT~xk_;9w(08GS})3HtUiw=ue|&Bag+ zurs#=I2)|oVM-6i`6qB}7t&&UAi|ijy9|v#JAASM93oxjV=0in=4~y&yPo#cHCJip z0>Nyap>aQaIiIn}MU=(*m(jJt_R+ZMe_`KyA)=IdJ{oD_O>P-*H;w&KD(u3%^Bz(O zwLm-JuT^}qLw{1T_Zvj-&VA?)PoB|U&rcy7$2q;jx5HM#%Gwut89Ny7*=S}TR0$F< zw$1k-9kPqIZ0<0XDU^iwN>v<71S~cwz+&r6QzdPp17hZ}=b`QS)C6XBT>M84iB<=6 zs8E`vj+V05h0ddzJi^_zrzl3c_P_T7P+MHVKNyb8GKgZ<(y8I5{2g?CYR;ewL@?d& z__S!qEo=lc)l;ANpzR}IZ7%%9+4H42b@9!g*hQ&Yf)W*YF-5j!b>R}!P1n=iUPw@6 z5xjdEb%dV4$jx*_@B&^l1^O+N7otpns*|La<*1*eIyjv0NOXXBFzz8K60s3o<~cIO zhxo$|OAm6C&ttBN?5LSIF-RVGhm_L(v6J2hQc5J)?ZEUa;X^@*E!7~{gV$ceMGgGI z+5rZ61Hb&7oEB;Sd>Y=qYrD4s^}pC4zv`NM0#yT(thoBYv*VTUdY>VDcu*=aR8D`v zGC~CAJZ$kBI;69G3Kiz3jXD}~W(%{EN{xnJZf(9u>@%e$qFQxgpUtGB|1p2dDg?5q zNqWB}z(e5GK#D_%V-`0`g6$i?31j79=?})=d~4)C z^F2i^XfK$MW!Nm>&0Mh}T~W}Qji=fmrs*1E%7wHDc0^_!p9&2dME*7U&G z?s9%_^XP)&+D`}Mqj7R}V{;~RjVgR@3Pc-J8)3!OCO~TCk$DD`ykqHbZ39^n*zH!V zPy2Wb*b8r)j|6ZNX8UWEli~Z*4=aWleCKQeT>@>Y+&Wm8D5-xn zLN=P|$th-3(oUq?0&qhC`^~K9JaZ?_&gD^17v}cHJ^!6j*IxS^O^~13WA@eF;0Un< zu^T)~H=_pp>lrG_<1M8(7aMkNm@dWhYvCpA<1qDQ|6Wr`;z;uOCqUMa9d5zxyX{3N zsta8}$w74x#SG(Er9GTdY?gy$@ZIXxz^x<_J=Ll6@}nmnk6pSqQxu9x9Y5z^E?PCR z7<09iUXQxkQUSlGyCdQtm+%BnQt}hk{)CRRG8^{=kfvQJtEv#X7qgXG`gyE@^_PJS zGN9(X=DpCwB8$FgS!C>K(;jP`O#5eyoOro)2{Gp)p;On~uo=|4R;^$)Kt}M>JT9J| ze&>b*W1l(85s@xV1BeAN&#x$o#yLLT9>?keFk){2V+lQYlj1+R2BW_Cc{Zr2Vr%c6N(|N&v?$;N@ zeBc*&V9p2Ok?KOM)-1_wve=S3S}{ew+U>;$rWZWmZB%`Mg&dxBwp84znM~CGQ~SaF z9eZX)<_*~A$RkRwg0Q5Ouzpb*+C19+&07fU987`o4#fZ&xk#&OUA4kJw_jv(Ya~pD z&!lc$Bb#-;IGO4Cz2-P9G&3X+U3`UGWPRi zaRJMJtXgJiJpOo5c7tR3J5Hq^4RlQFYO$X_Vh3Rijy=wn_#QDzd_;@^oXs*rMSpL2 z$P1OIK5enhx4;#Vn^{q2F`Lwt_I|)BK33(lv-J7zcA{K4tesaa9D<=o;&n|XFisCA zb-zq={fZm8{h7(Lto9|kIl36cNWyQ zMSi$@>v1hC@H~H;(ESX5Peyk3l$UzBN_PN?CNT!35gFcAiED_AeAnfC@oYk5!V2!& zcck9WX`{2ceGy1kgoK$fj}MPcS7;xSEj#Rj-o2Y*_ZD`kfRAm%NIF85{Z7j?Ta^4P zyOM#`i9cUa9+-?T38Iz6y(~TD=}Qj+$fnFCECOUX1x+>M$A zM@xBr#p)tFSortcVe4bVFBQ#SCbuB&8c3bi*E8EXm-bwR=h$le?AJY8+AS@K;g>j3CR2G-qpM_eyUpl9xKP1GG*19_uO+nNZtC(65v`A zcj`^;-06p;-CMB^;C{oZPKoziW|(nGqK_G8+=RXuQnN)k#Zt+WO-4PM;W zpb1M{A!3Wib9EyERq3bu&`6=u-C(~bWT|oXV4cXop_NsqAbmz7*Yt{edeV z+rmr7?MKZF7W!3M?M9`Vj{@UR>?-bY{vowyKG6A!o47a_Z}S)YY`8}JPf3rvjJ=zd zrmSa7bRnhgR34`thmIh}JE2H_{o#(1fD0)hn#HCKGSBs7%N@4Lvh5LNt)qDd@_+4~ z5A%5?c~~`be*U&+g}*jFl)FKmrZQ%2s~Ss)*8c5m8Q6iX*pTaD%~w=I!8w6{%r07+b2 zKix(8j6jZ+wjsGz6X z8+s;Jpx~b7?cd(%-+97kS>G`A{>4;7n|!VItvoHb9R#&}xu6;9mPJA7sGS4({*0Ov zQ(dPSyH|8ChxqF}_X8gj!NjhD9DRQ9JI@BeK{M%QTE&2d6*|8nqet}9#9efohS!4&KSEELM!EJfelB>kl(TwAL^w>3;XpdSwjY4 z6PkQhc93{{7}-;VxjXOw^YThPxu=9DwO}62c?w@U|FO$`p^g}JmDPi#cbDAN$iIMA zuB$_ZpPAhn^wjxLD6QIKVbVWTHu}?#_Y8ViC{i$kP-X?cRQsIk-HLm-n2DMRjv97t zYtl~UH1CiX`mWZ!^UWD2mjCD+=xiJLZHf$Q*!twj-n@7_x4u#_N+Lnxh=ux#i8-mY z3AJU?lb0gq(6uu=oqKzkbf0@KX1~e0O5F%2hsehBYw_d*2>m!tWU%u`*9Dt*>!!yj zbj#dtn1HUG&JF9Mkw&5%Jr{lyNYt_EAxuQ~T0K1DCiJJzFLw$}4bzqWZI@{8<&UJFx0X&0Gtd`PP;sY)A5GhqMuBVuCzo#MhQeU z*-LkiZ9fX0Y+%~ zU0k~_Dm(>Cf1Z;KS}QKV>=A|ADlb~9$W%Gt)g<+k4N1@XvyaqyDN3`Dr0TTln3ZJ# zhT>t}jG>iliAw9tKf%h%#TeWYY;9`!HpRf!O41RAqrC&9eES`%_K_~9E6QfQc5C~& zrb|*j>*{p8I_&YSM$n1!T4=){!-{@Ea^@}FmGSJ_aV^}$+2N7#K(YLE{;BpsPmM#f zYqyKxcCGITRh?_i6#E;y#@Gqaa;?t~yiD_MrpU*~;h$~4){rNfIN>o_adlRx4We3bm-~!G6ElJzoe&nmj|4eBB9(zF2m&j+@~)(Wj6<{ec)eK}oR!t>_f$!BiRAfbd+!NS{5S#Zur`@0*rS774~&{s)eptG8ggH}Kw z&tLXg>HP?rzY6U>aF!FFIoZKVi_KCtL7Oc|llqfckKDJ&=A&xi{G{+t%^EyI{Mw`Y zi4x0%=4#sqsc+l-uTw!%P}eF|8wN8~6J2p&Y?al+u>H`~y3FO}!`(ifTQzEc`Iq`I zr64)<00`pg&))MS^`~n1e`4faW_8j~?h7+4{ZBb#BMvS5ER!lNXFInY0xita0FM2( zl6BRD`m9do*RrTo(Az3^N+PSidej9bdjDF%%J{)J8a;RTL4g%Q+4CS7N3Nj)!6PH3 zDjdi+*)NZdyT8YL&B<}PIHVI*e!vzug8DB4i=cFg*!c^c&S%}-k+Ns%PCG~o1zWX6z>Fi zoj6h2p*T`jY2OIsPg?%akUkkq3cmN`&g&LL%TUEpk#7 z7q$?2j4jW0KSr>Wj3~BplRs-0M;o<}_L-IYbAVXTB54FcVe-My^yy>*-3DU;FT{j$87Dw&@?tgbd{adFY&xdlPGTAx?L z+1Gh5S56o-Qw9L$B5w@wqr3f+X2UOWWw!Y<*pJ89N(ZY3YnWYgbSm^C3cfzQJ6!0|5{z4_nI zxv1;69kn)E*kRINJrOe^YhTbVM{ttMSTZmMz#sqXap9DfT}f9VYF9K3l!+ZVeutIi z)1z_tg;4Wm%7+}LRhi?h;BUZdJCsEjw7#kGg3S97(aSpn`H(8n;MKDK zYB#IT2>5wiwlYQYe?7@(A=}w}AM$n9p6T@W2fYKv7ppxZOuTf?_N7DcHz#3xAeHJG z5izf#6_uCc1hNOf2Vj9w+Wjz~AA!i_)1q3i~CJJcd4{aY!l!;zf<27&7M zaZ@XyZTGDLB70)5c~2g?|ATMrE~xVehPj;&V+8E~ji~v5^`ZZWLqs2K9^gzoQ}8R~ z8UbiQ1ORlw@&=ebRVkXuN2j*3_L8`C7WL-Bc&Y@eLOQ0R*9{v^S9{_kr zZqooDy!!ge;F|yrI>iOxCXxS}sN5N5aSV)>t?mD=S zl4Tm`KUn4pmhgiMJYoA-s1_hF!FE`{3hlG~_;P-|zJ08iG}g@e*z)U~owe;={qTpw zMYHy0h4XGx>>;G*e#@c{h7L5mDReKm+s*bN1R#afq$DFqMnA`jS!2y8+*pM8MuF|l zcW=BzL}f}chgqS8(szh|`PA<9NJ8%8J~P{pALQ*}OEJ-C1IvlBa?F_MBPVzM$m zu*j*A*KawV&hW=5ge=|7!J4Z7eM5N-KCmC$DFC1g2>Lk5w*1ZkKtiB=W!g~*=4N!; z2JKl1zRTeK7A~`pfOcWmn5l=t*h5O(!>E;WGB_?^vAOI`26!qI2^=aj;Yn1S&hAqM z`OD(2OIUqAoyw-lp}(WQ9~Ylu%k-*{3A=Z=C|&#ehffy+u!;MVPfp!NQW7zuO<+*s z=1vJJL>dc7EYf!}5=hBUQ9y1+7qpWBv`x|-IeEmIo848upx)PnqglYSmovC6fXeCR|B=2sReLWFjzV9LKg!-O=`bqffmo4G{B;hSWJ7yxv^#uO<=MZZ24(p={UpertDd>qaT!T#&zIQR_w-7PNUyBKYqZ7rgu24Ha@>Wlf%c4eQN z{`oojNFk)S*u2?`w!|azJi}})+VoLVfc@Yjm>qG;6*t`?W}WEsQyt#4Gc*@~TnKow z%XyP(InVX`p!>=&<{QORPX0`L>pf}#lCk2w_$>beaoB6??L$*g$N|f3r{St=DijIK z#$?kBgg5nB6-+Z(2yoKS{p9sa-eVGpP;8U700FFdkNNJ3lN~r7N=oQ{JI&>a(Rq)3 zSfJC8^rCx0eTxZAbATIN@oWq^$W0{Z`7t#;x(?LALRLr(;NWQ7v$(O`8ea78+LE$t zocSZ+hOfo?$dF&wC!2GebXtH^iK{F7r9)kAAMMOUbXS#1Fe+KV1delTo6b0jJ&Qf( zL|z#}qu)9JYqOYUcv%+ynDB0|+T3jVqt9I`6_ezB(EcUEcl-k@YqLW1s4>-tf zSwS(zE_VMgWydWRUS*+84kU_b&=Za;^YZ|91pPx%_1N>lfQ+e{hRHQTCScZ-Z?=o1 zq%_W6{MNR^!|Sm#BWEF^%jV5;uO7{7s?6Oebk%X2mFu|ubLZ`$sWMlCqSwH@%VZ=< zolb@owhz_u##&UFMhDl23V*KgX?imEAlo0L+0~A z2j;*Kux@b2-U9pGZIUNr$n!(P)K2jnI-qgJK&^8?y9gQ7u99^>Yb#@+(#|v%!&r5l z6Nr1+W)LtrBer*b3HGMwdRewZ$WP!3#)P;-D{O7pO%x!A?%^Sw(kpMp3066Bw+?+h zWq%y_f-UNHiad2LfB}-=vq0cHuKtR}<(E;PPU{BMce{H$L~B#7FYLJ4UQ$i~+Qt?V01&r2kcsL8ceWIAEfzai+BOcH1lT zms36yVAdu$Zm82ugtY}_sr=qro9}(LSzXiQ{;%yPe~JBDnGwv*XgfI(&xSPvh#kG# zvl^Oq&Ye2^g|cAlA6SRW3Qmr#o~kFXCi0o{W?JNbuGH|00_?sybi?Wc)esh)5Qn68 z?t0@i+kx#*ze4?2?V2%b38?4ePX||78IPIAeC9|Q1WZWBm$~8wvaRaalk~ya!5N!_H0)(0!IQJv)=TlQ`)jwM&r?${oS49vJ)*{$h?ssmC!#nYW26(JHJ z;$G!4`m|HN6F5I?E@#9c|Jf1!dH@o-kE}ZsDT9yVKq2JdlvYo_PVdo)+l^b$%_wi@ zs;ZKMQQ}9t0AD#fU<)^Mch;wrS@{>VZ@!M)Yv7}0?^8SiJFSvI1+ke9IqCZmJ1MLH zUtgm*Ac9qEbN)+VZL(i>HLmD|0((kNu;Q2%Ac79$Qb&En>BV!*UPv8 zGBLz7GlckcuhHdlirCmxk<-Z`jws_~ww|sUEMu*pd1f(aAV;if*;88e9cuXb*P7Hb zscF8Td8=HbE39a{1IO7v$l->mLE8%w*xT`2_fTps6OS1T7zYvolPWf_L|7d#dd|(EntH} zeLlQUK;{>^icOp0a$>G1 zXO`>7w_8;bF+$w3G-5-l)b_%?(#8)c6ul@W*7vux;LMGcb9?zI@^2EH=38@j#eyK2 zs?HOp_2z&t?F}GX07;6{lH7be-U~9=VB0mg*#D=5MJws700c@OYkX6q`~1!IY>{}6 zMOd|adWUlF6iVV=%C985;luqub>8*-vVsrcA%-@vyYhp?8*(m{{P6Oz$7yCVsne-CB-Q-o;1gjdcAJY@j@+7%#)J@%eJn6a^JZB$oYBfTzbjm;u0d@QhE~eGq)i^VFT%YoBYcjLItq}9<)LaTKh5v9ka_d zyGJa#_A~Pk(L4nR$bBA(22V^Yr@8HYvCG8?1ZY55>OO9dELzHnoH#?-3g?U;NkUmX z6nmu++UCE&hxjy6hQwzP#|sYDHgdozck?HtIa7uMr%l`J3Nk#QrPngf!jD#y3-=#Y zfx+c0WI+=v{hY2N=!0wj7m6pq2pEnfrISnbN57*RGSTXYn@<}oZE{Q#%QiC}i$!hB zYpq*MS#@Gr8?CT6)z3N=WV?(Uf$2=xD=-e(4K{CIfJs+tO=VjLE`KXvoflH92Eu&= z0b{CSKIZh;hW)-&o08SY83jnla}|H*4(yV#r{+7Go*Dtb1qUR(NJ>{*#Sq*4=Xvf> zmqI!(ZC$XoIX5gjS^QX$Txd3=X8y#hVR_&Ia+7ZdQd5-dVAE^k)pbX3e2#Jc%d;{W z19*Ary>_aFG0!Rt81e$<{9ASZtQ zE&*w8?4f-wmA@SPT?w2vkXMIlxu3R->96n^Vvb@MmegqMesmw2?j`Jv+zUYM0hzqf zyC_;d>I!|mf&82#)zLJ6y~MI=F@PfjYwz?-TVvJR zuiN)MaYXz)o%JG<&hxHA442?3SYyTd0&J5OEi)K@xIZ9R*)#CUC^!E$pYZvlO+ZZ( zFRjF8BG7IR`CvHCfwZj87^V__vowh%8Iknq+cer#%eZe7OLcJ42z1% z$!ys?n7BRlFz;mU9kOvd#|a-9HOucDQcW0U%n|MvscvBJFR=qqFouWwGO$NSX*{R| zK=Z%8AGxa=B3DiZHl#63zkj%1#2^0|@7Ay}NK(;O5N)5$@XBS$Jvgupi3pwe67pE; z4(rzz=bdehcs^xgh>EkOZY_62OD*LnnH;<(`!x}iE+fvE|F{b)O{@}kjS(l0nbQ-7 zy&BF|laTZTiAY~TaW|nCeQJSl7yQqGV_z4q=n-D?YCf$ZlMd$9Qq-E3j0;3(oUM1S zQm*z--taFtamF2BElYvEu_~RCO+(UFFt}q+u`ing@WgX}0s-Z24eXN5V`pD?#oJ+H zmA-pY7@(?w8!fg?+X-|R>U9qe@u~<~Vz;*HAo09(>vF`I_<))BHuVyvj=$a|@TU3W zu1yw0WAur8*Xm-mNsUPu1wWOSLqK;-tT@YNdyW-a%ohSFPq2}Xn98`#%mpYBHU=bD zGD(a{Qjsc-AmiZ5+mwTzwW~&-ZxuBxM-hak8R6~bx<~FGQ9XUVl2drdWL@I!7pk=A zyt8Sq@(Y07j&4Wf;)l-p<|&b>%HQ6`F4Xwu?-4(F`e2T2C+MbyI)!%i6&$9Dqc>{^n@RguJ-3TgWo?-QMhV|zs>y}$J$tmP>cV!6@HgbG1T^kkH4DT&#j%5m)Pa#~VsyzmG2}>1=;I@oZ4E~O7 zaA@6d5PsqbQW3YXlgA>OBflL6=!t~exM*{XNW%w8J!6|1uc>*Ai1+c%cV}Gg^Ad+Y zu6Af4wHIG+xQ?&1JpU@>r;IRL!#_Ya%Il!gNBqm%&o~&P9z*VHjbu;d#JKZMOqqZs zxiu1daz=N4sp?RR14a#3pX}&@gH0FY%idzzu4bTrrg7AE0g|fj3;eW2x*v3ZcA!k;G&o%zxEGWWks|nkapp=9-(e9iqE*g3&#U-uF3f13K*SHCv_#KPh zR1&ISl{R7Nb7jya8e#-noXGiywX-r@9Kl!YBoeju>!Ik1oB=9-5~3(MwHnf;^jca~ zxSu)w$24YrDUjz!+0`C-5%&h;z#-R?NyQx|7z~lrU#&kjf1G)G>m$_*;N~UT7fU`w zU}lD5w_d>yUCkohM*-_?SX7tA)y0LL{(>2KItzu1R=;hD?KfdtJ?6G-kw`Qb+fL9d zWsE4#YxbG4eftHRjjeKAEUoFwX%9?vTIsV;!RymPv>~kHBPSYjNGrV`T$eT zGJKl&;35lTugGM?A zqy-5@5$P5w0cn__Lpmg+K?McrkQ|Wil5U5tp(zwx$6K7qg7nkHI?qLm>qX)mR<}ouXD*>=%gE1{!*NLs z{1)t2np4jQ1}W}Y^V_?pxQ%3At;^tu&5pha#m)b#DK=ptycfy6H2}V!gQDGF8*Wvw zw4*gXz*)an6b&$%~cYS+Dw-g4odzg+zJbSwEYA>C$TB7O=kUm7K zPH!J*_5er)vg9NekXtUYm_MjdK1AnxWGW z?QP`zD55X&xS;J9cW&nplS5p|HaI3=lyTo+xo|3jX1%{RL3EMEmTRH#1X8giU0oPJ z^g6MpCBdQ`&BB-Mc}JT!Z{Bz{y5>39Cy(O`mr5h(e*aufwD2ls(Rhp}^fdLl z35;ZCl+!Ox>)OPerytVwHql?<8 z3fYl;=cB!B1ei_#nbT8lkJzT$_mE?y1c$7H1Yh!Ti{_Z6UZ!Ch3(3Q}Qg5~Tr{5{L zb4>QYGAc`FAPfUS7ur~{RSH5XzKA^z{rfGBu&o&M_TQ?MkE?Zj=wHmv(IPgaJy#dn z?HR_2DSAUHMEgr1^I`j&{VJxy3g=q9azWUSP`~c0Lal4I%;nfYxh-EPeBt;@{xZN$ zD8@4QH)3kpGGi{_>k?)Qz7yAcG}#Fk1YhG5yE#jWedE|Ar{(glxva+%a%~sCtXN@- zKtn7_4=i7fDP>D-;v>ogf*@Qth;ICL2E%3=Fa=Wik4}Mm3L8Hu4Uc3p%mZ1fae?_} z1VI`>D5({YW_pW{(9F&wI3IzpKs2S1VpiRSF0jFh1w5sq{o$sAW>5bTUuf_)W;U2o z7go7xK_`UzP)_t0Rd^3_oE2>SyT3#)^FoG51{rV#)R z)=bG^?_W!~mZsej=f!Q(pWkeAc3$2L#K5f#2)$Ix;N9ST5&qoxO$5?$es? zF<${(=tAu7w3#};M-C=->^r33amR7dZm+o%*;(3o@kLdeLA;sh_cQ-8&A{TrT$U^$ zf~2{0Ab1SSekTPh|A2zf9|M&;fc%*G_pH&wzYC$;_dWET;wi$grz{7qY$IS=zeT^# zvCNOB!2yxe6i)7-y@!4)OqB|j#VoRGt~H0Xxt1(@ju@@p9{-8P>zDw#>1T(!_Uq3T zPEGTu(KC%Es~Gc%??s_M1IHkeRE)2X*!k?d(@D=o?zLJTiIo+LJ?8=Wy+uq1JmE3U zcxKd4TVGOYAE=!k|02NmSBxm5x#ufnC2C6Ac8^(uTp)G<2r_(JpB#($xQJ4*JB3Uq z1k^9PwmsKV<|wk6nze*6lb;D^QbALYIk<4>=vDp}cGDGW_xakEHVYYtONfog(Q^5G z$+Cg8yK*m-V{^RbI6?pNEb%78k;(p@J#*RIN+TLA7lF*RubGRh_sH1}J*q%N4va>g zI-&yt%h^FWERy7aXbp<2+pu1&1DG}kZR(A$oV{0Izb=;4OP6?SrfQ|Wj|4aTk~kmU zyT*3bjI!I>HkYO4X4n1koUcf%hL88Sn2be2xqwZG%uKJLR5U8lM zgf|e=Y67&Vfa@YFA%GOSfv#a-=c+Qgq{vydkRL7{6|#Ts97wx255)KS-;nUsZ>72q zgt7ql_bvMrDAi=1>OSGT_ue+)1>_>*{Zva$Y94NFfLk5vULliIZXVG$-v#8xc}c$g zTE8RA2PB>9vTiWy1S*!1CVyJ3WZTDwDTk0oGIJZZdTcT%xy17dcl?Ih*b!d3=A>qq zW|Ju^?c;43vSd>OI0D7j0wc{Od2Y#?YS`hqf-_Qs2W%#PTqdTcxXZ1U_p zn*2{AI5{y0zc?IC_TC??^DgBfpIbyXXv+!!?y%m8A}=QGmFyMwCBX1~plcpTn&FT7 z6a5RqY8os>`^1aZUBOs|SZaisBAc6;!7y7~sg1O60w~k>fDMt*#lRyz$_buBer1#X zq!^?Uk<<4j@@vPOEDxX8w)gaRbCjQEyv+>~tSsU3z?MA50 z1F4`Z#t}k%juS=%Lgt^fqI_KEJ7t}B!MS*us_srT;2kg`-8wg+{*>=5c+B0-g65drjGJ-Apver z8?m0^Qmt-c*Zfv<{5p+{5t4spIe|UqLV2PHO0ZifFmEBm_CAUJ;NEK3_mz_>KGqG5 zn4s&_XxJHdX7q8lrn3TNanr>qL?7&@2T(*(%dnHx?}P}-KcpRB9< zQ>ww!B90bMmqd}oSu1POg-Me^H4@+%YP(M&1|sDj{@$dkgfP$Be53wl{Fe3$|F30P zjbqevWVSxJ317PO-pUI=Y9t}*9A9%-xhbp=n^L(F($Wt(73s|acbZkjQ-#nsr?e&> zXdO5lst=q{IA=J2@Z!ttyKork8Zd8HsO_nunvxV5HRgWPoMhzJEi)HLb30@o#G@*~ zXu->Lf*{W5Rvh;ZU~Sea1!eD;=;Xnh!BE@6H^sz}|s zb+LDVSIMZk<0nOJ!P^G3wcB)Q;&hB@2x z>V7KORDnHz!0^Iv0j{H{p~_x@gt_Z36OsnNPxo^gp}QwDcPaVP&#uV1L4d`z<~cG; zhT1goIvjq~S~J4WGPMHxTF#tjI#aqE(Xt6y__`l|8m#k|*Z2A@VVwna)vMURy$XrG z1_MhuZD02*?1_(}3Ql z{|h`98%Q#MtKJ>)Kde%3RGL~SU3V=N7(4|W(?6n|(hbo23EifSjZ$i7{drei+oU6t zoB!BS+7WjF_}t=bPuftZTOk>y=euzq@AFy5cz}dQYuuCj$`x2ZTy^Q{2uhZp?hVv0 zynm7xXo0U{v;i%B2wO<1*(v!Jo2lvK-Kr(sP+qK4zBenjd}O}yXB29AKl;}Q^^5_U zr14+~2O0b+3R`qpa97EpF29$-4It+p0eqojzN25qRmP<;fQol^_RYV912ySmP6%~Y zMVapv$*HCtpnW;DW=@$kGlw-hZRi)-V>}*${S6*6vKtucC%p9~+8Jdxgvw=gG;vy0 z@zmeA(TY}entk$Atl)j$ehW+sL|Mw}AC;pZhc$jgvf z$h2|@Qcr4vDhNJVT_)&HQfno)C$ZlLAeIc5ecWpw?~YX(3oyK7AY~=r?Ll^M_Hr`W zC?aO2Ve+qu0Lom${i;UrpR{oGHrVk!fRe+^BRneHKOgud`f+>asL^urEI-vjEyafb zkD;uwV8ZZI`Jn8SekpyJ4|L?+IlL^`LDQ+vA~wv+tsB2f|K1V%x)AFU>qdy;+(U;M z*S~piffRbFh(3H~$2&xc+E$Y*5Fufu9w}{Rfr&H1NV5?YVglOCzYh98@1!CB)*nI#uvAc`iZgTahOH2KbQD=L8xInKlHCEh}3b`N#PfIH};tY_cLz zN=WRs(fY&r1m&y%rd!Ns6iBDIF1$vL|8nNeDb@43Eg+zHwGsc0iar#N-d!7|1Dd&x zGfrfsn79S2A85oqj30IXRtG`PBlvHCP1lzH5R;sTkb0(8ztz8M8S5W$!ALYXm)ZWC zz6<~cLegs%9H`;^1g1T|a{7XW_qpl9P;T$kFkE!J?mWQG-7Pr_T!@j<_5eFv^23%p z5l_Lf{*c6>KJvzVu;ONxMAiy0DZoZ~2vDlvxz7}51x>Z1)U+Aiqe@IV7V(8rI)5|x zK-o(l3*d$e-16Jh{0{K42K=5|14zRYGc;Qm)a$t`m`s&8|lRQ%QRBg+#u%0kjjc*_`Cra z-wyQD&KMTXx+M;Z9+t~Kxb3Hn?v*;vb@opQCVfQu;kRU~W(0SPDu5HZiDi+#*Wlcg zS~swMSRoFe5{7!6%7RuoL|-Hx?67snuZ!nvZTku%Dg$@CPU%InMY2P~Hi?Nc$H4oh zGtNGGx}L>+bCVzM&c3?5OnHbM$lxMuIucLMFzk*;&*pjx4E>Lc&f*-6fd z>p~Ss(e!KL7J-Aj#}w8X2OIJ``1S0~A%t118NI(8%c!p;Q*Eg^HIp4Znw(trV-j^w z6c0OHJMZL)N&3ZK_Mfw;Tk$D7Ir2n&_;cF*hVTJPJ#)@h;P*yBk9-vI&P&j^Jw6NN zJgWOn;TdD|aI*ub{-+z+@im}(a7jwM)+x($TU=32@rM_i^IQUGX_Y+=0qdY$Za-DU@0 zjzQZ?&vENrlx@*_Ep6pwa)2KA0r#?Z)i>5T*;oG>pUFW(cuC0%$$*{m)AXOgCso3QqiFSZ&x-wA2&*X!WHgk9a`pWp|!8R z63#&jbURwMysuk7vLd$-lyB6xv{AucCd60ba^N6(YplaTY+ulPS{{RAXxGJ_OkSRQ z2l2+$ybhm1vob?{^K_L%HpKIjwQiiV^@K>F;Y{O~z@t7OQB~!WGz8yEyLY}9AS9W- z+zZ*(#`eYVXLv6=H{AJgU_8t_#>r~1@%b9#YZ~JtBziR_bITRgb{Q9cl1(zrT(IyI zRu_#&uxTA;jB+l3`vCmAk)Iyh+zMA6?lo-Q{J1vdNa5Abo3!N9eN?jd#L_xsVmBMIugq@pn)QR?|KtY+nE9f1HhQyM zUc4UqRk!-5i9IRqpqiM5Ojc%E9GG7Kr0GWV<%3Z~yV($rgja?qKS-ZE>rHoFmwswN z%)-kaKq1>>&73%!Yx9Ka)PcG)$fm1h-((QJ?e7O(YZN zMc4rkrW?Dviu+M*rIw8S&w?^(S?)e+FPMi5=@I$Z7p6b`LvET?#{B3v93BQeT)t6V z(@(wbT{4y>pzXQkqGU2nCamt!8v7f(ZFfOR?S{PMlvR=vP6W^*M28r*-YlXP$GO?8w`)>b42@BK+`kH^Am4MwF7C4}R>zE5 z;aj(&DH9{Nbud{qL&PCn3@Qy2O+e&JC5F!?(WD+R#z?y0{>oNedQl%1?&UEJlu zyH|O|DY;%7jy=0}>mpLR%zoHIbyU^~5Sdu%ScFMpLGdouoHJ~HM_bZ!&VvK8AB3=& z+C!A52|M^uTnW%>K_-SU7)KSSM+TH;w_3;DygDc6TmT4qQzi|af#)3dB%jl8(*Qc5 zaRL!npe^J}z=MrAEFk>`Sz@<#uE{%0(@XeO7b>iI%gF}ytxN{|%jVrU+ET_lyMG_PX z_ab%~jrt1$p|tF5a8iWIZ3eSPMAkChtk8{>W2U zQDv1w53l$IW--7dD4@3W7r3h^=ejL!2o(}4Wn{F*W^}c0NTUT5smeTKk26-vLd$k+ z-cDX#8%V!=n`nCm9=kKGxcP3+;KcqVv*{X^^vz)sVpR7ZwrBeH9{gHWS*lgx*2B%U zxxBx^X1uJH!?i+r?2fc z8>vR-tq$LfNq6qr|l7a&8 z%xA(SS#iu>nw#)3BIXBJDfjU4Y`|~90U@dqNH+dMTtKn%PrK|YN*6&fm~gQUrsX2xC>W9oC|>cQs2 z+9Uu%XS|q~0tEnqF>EO{IjhWvY|QNK1bwnS2*6P6f4b#4BD)}MR{M(evfnZzA&Xw+ z-)uAoajm+7oexAI{&i6BOZgCbfTm3Gi65%;!XcrEFhUzBA24x;b!ZGxmBCQkcR*;f zLUo}?BO5`=ur5t!K=ejB#+^4`xNyFmiIi$x+cT&nlaPlMQou46iOw${umZ)1P4r(B3#I!HMG5<9BIKxaL7V9_`q`B2dmbikpG5wDF z>+#$Q9?~{U&Qt8(GE_YtRAqxifBI(+FE;4U%f_-+kv1asydFZM#f*$kt;ic)ye9`^ zGc+SUl3>_iz*3FQrVx7O$(yc5BN=kFF0@B|P=NE`a8dAqjGVnYR)mrcrOl8tYR*v4@*GE+;p z!kXM)M?#21Iipe&eozAYg7H?jMV=42aZk0rO!Q+DVJ3M+F1$M*Hfe^F#Y(`e2-uJaF$E? z$p1gEI{A?GTLtD%!CV=3$ps?4{Y+jy3OY~Xn_1M?54PW!14;qoM3qo}Q07GYbyNw1 zMd;XQRXnZ95K4YOOPlxUTjh95509@K{E8o~c{N#ZNGY#+!)9az6_IDAid7M!M!r~Z zf6WOhc&ki+HcMI{ewhSguMlV544Bob2r7a$Uu9&r?<0-ZZi}_&_{nNE59%(e(_9kh z#J$JR0>STqssCiZd{(aJFOa7;Y|&`>5aoUSL zFzhjlL`AIjA$VjZu|RjWo*)Qc;vR-3Gkcqw#{fm9-~M*&OU|z_4H~0KLZ`>>iP7L` z&>2ZJgY38AdU>k(`DWV}%*g8KvL&9a#%TrVNehx@=S5Fcg!A`FX6tA2seCoguP=XtO@&v^!&ei3LS0*y zyf)hW4_>`~LWK7?e00VWvE0l4Qt+7vw5NEhS^?uYfdeT?Ew~S!ggh}!=p#69PV2H8 zs&i~l*}a0TT`a`K=aaWI@2cD2UmEep3V9UbDLYN{x6OvHe$s*(2{x`4AIy|i&hFK; zU%Tii9#*#12D@#SjJpAJL=U!Tu}vp)8BILC9zQ-zdBOl8c@9>71T2R&J!XC{uA}rsOw%otT&w87>x&+`=Vk7(mgDqjBpAE~6HuMrhmpmx!H`-Kllg1?Ry7 z?I4PX5a-Mj7-t=L-Kdjqfc1hM6q4%%BSj(BXCUk>l>W^UYs5Lx+dg9A6k29rDk z-Tp2~fbPteD{N)EcOAGGLq1PSZcWt{2kr`NxuP60Fzc&msjb}J7y*W_dVAF`#H}~Kfe(Cb%}g# zI%Zz!`mop8V4x8pxN!n18*1M~Xl-gR*u$z*t#?=KvG4&H6wCLG)flJXdLC;4xRZeS zDBkDklUiBjN^yc>h91^mA(RZ=?b6Y5oF$zajM?5m4b|NZ*A}*|V^2r_G@@x?JT1rQ&~k?NBw;VxOBx<+5$UD) z_Vyb+X|6sh1toGSpUjunf}~oku)fDYeHB2Obt{@@CZQ;bj%&A9a|_G61+eNO(c~dU zOm2R+qKC^jII7$ZH__yfD}l(0mD>avsn6TSb6JIUA2Z!4^MH-^_H+4eC-hydOS3L! ze_AJ85^H7K;?r9#%6rA3r=%)C>Bl;5l=U)H~P*M!I)Ve zL29YYBjG;k@Uzua{fG;8hX;NYcf8i*hmuV(uu>QP6YJ+e+8q*0m|pY@C;am)^m32~ z&__u#xwq}Yt^?LP`Jwn7WGnWOU=?3XQOofT!lzBOGXFuNs*KX#F=!H5$FXn-RZrCk zuz#@;Rc83RjOzDPp+87ZD55F(7fbH06`?R`)ePe1q98Pe+ujlxu-<=h{~X7~8B*XhL5O*m{R6_o<1LKCzD&nTY6*ByZI4A0v*0 zHem={Dv30k*_;zWnpwT?Ry`p*sp!uFTVJj@2$3+mQ!1B7NJ91)0M`ZHfa_wf6mF@A z(ww)DOW#J~Wy);Jew99;%iJQ;l>l5s7aDo)#3UU8rLbw{&1E%u; za2?s`@n3}lF0$GRR4smN(6Od@37DYZLO+kgitqGR$N!Y$)Q&K!V4e(x^ zB>H$H{GyjFD<%;I;LU}x8v2{5m6tP1E0GAOi6GB?^$a%Iudy;OyB#NzS$ZX?u~2VPPpu#5)UQJtq&DJ6r*1 zb|G$YVfTz6bCGAk*o1TAvR-(Ga{ZUR=`|;2j|L~p-C{ZPkgI6__i&Fc}!O20OyJ`Nu@zgF<|i1r=4;sw&;v#OUc!wQ$idoh(2 zaH)5mqEKT5hi1t`k_dzJt;_6q{0&}5sIWv8Z%aUaDwb$2V>^$>S-(w*aCJ|cVC zgx&c(1NA;cd5Ue{0Ck35Esthb(VV;u!v0GEyyIv3HfUS(L@;XQje9kI-curJc!o*k z$hX0y0V~!T=Ln#JMkWUS+f;$3dN|Ud?8r5fFV6fl1;E>}kXK=WIDjmu`u-49KQ~Xw z7ZPRyQQ-L4)->Y0j!U3MZ9i~aCA^rk=fTCdQnDI3x5d%;{1iwPdt;h00OlM(G$Tr` z%j?WUd1N4CzfsWhW`SL%-^UjV=~=Ik;77y-q5H$sd0UcUjd{T4Ys(`qrL+Ycn_Ide z8|~}>bi4)MbaOK^qh4+#va<=o1*Tqhg`E_>cZ6`TrC){0FeY-MFhzW3TFaRga`Mpk zpP8Gcj|-yuM3GDH&<6torOrE1<3ZwaBHyvnubUYF=!%Zr& z<@bZ06WTZvjSIA$Y@FQkK;Xt2FVj^8Kg?#@|9TH*R*xJ2D-6sWpZ|LcLC8=+P4?Cw z=&4NLetO*sS#SoH7K9Ezr*#LQ^SjC4oPFjd zdekn z&WEhfj}HdPDVFlMr4L=J4wpmpl~?JvcrHa6Kh&b--NU@8N#Al?|A^V+?aE$jS1As( zzU9@vqMpeh*FJ~I=>+8`6oi=3@g3x094SW- z&E?kBM*-2xx}$(0pAnXWVr(0ENWp4hJ=>N6r+seT3bmWXI}z(qohqgu@Rn62u1>1;A_V0`KeH8})(} zpgi6)m4*nR*k^9~M* zE*xHD%(o{V?@f|ijBHUwv*aPoa>Ez3BmLVh)omQfJkDY+97Cb3TiNT*5imUI^oQO4 zz7iM*EE(omk2OWF=V&o3F3?l$!4s;`lE)>9H7QA!&axlrrQF}A;h1pQ)6zxJtJy)W zXW$p?Y`?HJGBdh*Ty(RoF?dQ^f+NDW$or_4%+VzvBv*4I)MN%aFSN-xnt;8N>MVBu zi+voLbHWu+>O}$Jqv_icJ48|!amO5cxBtGaV1n*<%3TIKZhBwT9&3(reX6_Qc4w8J zOV*kjtX66Sn{F}tV(T6fjSHSpvjxxET$6L4TJc2vWf+jt)=8%EliGSIBLyov%qj`f zSmRw_AgKv)e;_JrNQ|;diM~+1niOm+mi53*wD#HKW464V%mVWjP_8Dvm7Dd_N-(av z%!*K_c8M{`b&|$Mn|$k!Dt|?$cw2>!VMk$pcuQXU@oNdOD{^yPa}QBSfIa@b#hhPHdQeMu*P7e!6x-guUc- zap8e;PIMPDPZMJ9`|rzo5jHAYMHM9yPWSB3WQ#oJF5m0qtc1xaW4J=sYAb(m zWTW=)vOs3V#uer%`-Nt}_K&|Jh$}nWzEPjPb(=V+ypbdaKi8R->`4-_HZ571TQ`D+ z2Wr`WAsjAtI&Z-aW|AWf&Kg8f5tta7B7R@tiU|zmm`Ks#YyiNvad@69`D-sl_xo__mZ_WSTv>hi zs(j&m9L-Xq=TFQLqeo^2%zWdL6MZPjRfxn=$s**qNZ03$xVz>H&&@dqsri!eTey^YzQ;V zV>Wg@0uj8=GSdUFONMpv13*z8%5bFxZGcq=F>?O0FlQjkfz#FU;P?~oR=pE1n<@mz zXMQ3^bBnJhwTGlo z&x^1*-q3iqH;m^}mj-m%j7OjGsdDghQLkJF%BEF(r-u+hFUFXHSw#~3NS0uxl z4W*Y6zo%;OPF`ji&%IGPWdHN|6$k^F+GsFpcPl*YKv~Y0wJBd2O@ZR4+5B)@Jh(kK zos}P+ZQbZ)L#pQ@;zX@(8+oz~WXKf0JUOoVc*x5IgqbJ1C;f**`!Ej)yypBkI<8@P zi@a9D&#+5H?6;B!tq&eC(BCbaz3XrImGO?sr;szV99#h6 z&}f9}vdw)(5WT!`iVj%5b+IIQIg%sou{k6SlRhmaEaFzV<@CFaWp{?t<206&g~Brq z@HkAvt8;rBMo_upKf4%?NAnxR4Her-AZN)JMr)%Mar&T{d=lk*f90bO?4hIG@JD@4 zHnuU?4Y6r+I`t;W2Mm;@eQovcq*E0BR7+!w7> zow83mLd2S5Lv(vGl`y+f%AfOvTb{5Y)tgw4oTch}QV32_+uKdsXu7NEOM83Sn7yys zCfbio;L2=1)Ywt6P7YxmSxBd6wz*6quLzR)AipDz*Asshu8>Te zNsC z)s7xJ6bp^Uq?n_W5_A=12d=(R`DL$tBu_xmjtTWKklQ4!RAMs|f8dyO=XMi1w8viN z>Mk|%%{U0{4lxzS{g%{`W>#-p1dTz@bboUvc+Xgy<^EVPpGnwz0j4~=2a8Tg(6%U+ zNQwc*D&n`g?B4j69Sj8~frPA?tE`hoMMLJvm+Ye%A3R$`?!N>dZa zdG9ZQXZGe4dul}>*vQm0qUxuV=jZG1RPb@QWhl63@9Kz9NnNq$hfqLs;QE4<)v6?2 z6iA55CV)l8`6qe;XZJor#3cQVFxp?ze^*aNzJ=Zrd4456e9b?zM<5DzTT2WX1b#lyk93G+(-woLJ*h5eeZRs>}zFb%v^Yrfedcj9uIjaPeAav_|sdOk- z(S^|@dIOG`lkG*J*8OATLnSPeYiyo&)cWWiw9j0wxm<~@MCg7GI>XmMMwr~MZq{%?K%tYdBUu``pxa5l%15Wq{7kSpnso-f~ReOc*ra#)> zC!Qb$KSfE(*GMyO6eJM12B90z4u8_5K)qJI(OkGN}K!K zVm78Bc*4Qn@iLShA}{x)M42cenw|ne^8vhd!)Y@Vsjo6bL(8hvk;Z`A@%9|nhEl0r zz@803n)oWA+BU4#zgUrSR>Cy&_0E?nA$?w4Hea)r=y_^92g)5s#lOG2Py$E zbqShFzW%2^exExmN~D>hhiN#0s%eRA`vkV4TJvO9CGs3IG)wQvSq~xzqu^qsPt&ZA zqAA$=To(m5QFx+z$5>5S9ZJtgc6qgELXJ)YZC@eWtRuGv@BW5ZWw{dEG*H(dP3u0HB+Lx9E6!(cR|nxTNjHSqeqUR9eV3QUG;nA%}t@y z3Q{cwl)w@k6nK^-yB0>bSZUe$M=OFBkNIpHmc*?d0bJw%w5Z@~J8x-h6s0y z%>%+j&Fj>cIzQ6Y-O>o9ddh$%sTGPa?cyEG*Lm3I1=h3Oe+N95i0VH8t?3ZpWpM$G z+`aG8a;)aP^DRBXoP~lUVRJZ3orRN;yV9M;RTJaGjgs#TWoPKyW6g}pN2qMVPTf0@G|E|F|rAMye( zjC}g_2z&s)OA!{mTUzl)>whoaq4(n5LcMcsTc)V(GvF+ecaz`a7JRpImTDa)Zyt0z zoA=@xkZ>v~39O2al7WF>YvMWe>M$L4{Vhw<)y${=rf)?gcO$p*5LEJKdRh|}CFxS| zSh2+B@eN0Jt8&S*ds& z<4>Gs$DZdY$hX)CGrecF&+$w%2BI%Zi;W(g|GnQ-et>7UkK<~_;Nss zvpz2H`nY%P{{QKIz{eh5127?v(z5DLNwWj=Gp61-#?Ez9v0bkV0lcx};uptq2Of#g z0;-$T>?TUd#WBFr+zh10iMEm2i#LN%{Th1-^9Cv{3H_;{Bnj>1jAZ?Hu>!^YpJAcK zfCOlk4ZwpFrZvti`VLfDRu57}?G)!x34JE|@Xs{xe5zbGPhss_nf>9k`1@u^T_6C6 z%a-<7{A_5plM5hcjwYTdRppf`I8O6>BRA5U0qP=WivhzbMzDEb^m0N=C_MdBn`?DJ z{hs;4rQ}})DV+m=wAJ8`IiKm&QVss`vbYH#@pZKVXr{GqGLikT4!vBUF}_6#iL0tFleH*XilECP1m zrc-hf@8)*ZJC%EQF%b?<8ySZ5@)xmkW6-cBUya(_;& z#eiGU)dQ!_?#t6+;Cc)gr`IV7H?|J^!)+)JywZxfSh&M1KxxB{`_EwhA^dKP;}T#1 zhM5;fuo`2}XUq))Zyhr&^)J54@^d&^?a&8L6cgT}D)Wj%B;xq;0;+eL$BWbr_i(Ol zNVU%oSQje*6kH|j2Hga7v;s3=nxn+@UWL(*vPwg)ua5YbgW0E>1S#!CvYF?cXP5m1 zY~REK_$W@0IRt5Y0kRW@a(F-&=XmW%=u)&S(jCYo--N=i~s<;MK?O5rThS|B?9du(B#h{IMf< zE6Kmi?JO%Z#tp)G3R%aFkY~7pg4@6h9ZR-vI8Wc2{7`tZ+*Gy!pk*-boAawuoH{l} zMpAh$wX*v`pJE}p?-(&K-17PHUMMG|tj97L(XiDL0Na2z@aq=#%o^1!iWR9fs$v}Dd#=xO+_GdCXYZ9si)!!%wXRR_}=C41sLM$vAk z*9|Lbpq??umB1~AZ%1{I3wXleLsxL@#YI@bZ#^Il?nioxWNS7DnET~yvE~M&AG80` z;M)h#zI7{eUh{x|GxdEdOs;SSx-^4$v1O>ir>dl1ax3!ER$=}_dmSCVWw=ctqkm@1 zvh_bP=K0i*dciXTWWe2nh2E~)!OqjNCSfqc!n-HGX9s8ZC6_-84n;_$-T~HaQT?=~ zVJsY1;|q9QxhWD9Sb#tjz0Wal`$_<)^(=k|F3z+|cQe?oShm#44i*8z;pf}@$7=oF zk5;(6*~Z51^n;=| z`Y(CvYer-q%jBh5$lYKni^!e^b^7$OdB?JjbVrGeNOc|n{J_UjGD(ihdjQy^9Cdiy z3-Zo#uFnJ%1OiJxOUriwav;GgtBsK86jYt_f&?%Me717D-=AZ}!+}ve2MhzqRBYJi z@U}p-8hJQht`Ap>0S)QI2Rqr|p{H&DW%f5AptaJO=VR2)5bkiU>Sn0Qr18L4`=@k^ zZGG!!_f%V?K@6Q~iXH-n!~Q(h3PJ;ux!(Ox>=}=}rgrS!Zi`CDHNo_8G<;tvc zWe151&~AXM+*;p!^)JSd2^RkEV!_pWuL1`7TOaVJD&9pEE8hRPJ?jw1An=x7NRRM0 zFt3y@;d)qHDWETe`~1}XSpYK$#A3E#fVo-%Dh@~_nO5(7&r%D^GC3#)c&QbKwUavJ zun}hKo>(_v*d{Ch@YO{f*@N}zw~hjr0Km1l=ghw`zg)wquNQdKAc!eE$fEl?by_g( zq)h+Q9pK9oM?TzkZ8`24ziNkAVB7prU&x_c-9yoDwY19=@l@f>~j=sG9O8yX}1YUD5B;Q#K(Y{gm%vIV4)$Q zd^`fMbnj9doBwu@;RBnVs)+T_6_0X_<+@;H9 z68YmiUsdEdR{hO!?B{3W^IZSMIz;OhgV1o?S3K!obB`;UUIjFg{4#FVTK>6M`>S0_ zex_oDWzn~|8MWliJz-q#V|n(j!8Tp6Tq$dM95d5|Q1Cug2dQ@9E4zh4z<}8)O1br( zrD4g-_=#>QH{0ZEC)XV;CZ|n6jiV&&NcG_{Ngmg4O{y1g)eQ*d{5&yj>D1>u< zq=1!V@!H+y9my;@OlC{8m)QH;|7)_K6+IA@uNqm)Q;)D4b}z0LX?uY)%iH4B8ptag zSjiv2NqOfk{4|!F!JDH;o_chBHv{QjTs%{_dVgZmP}M*tc~mVc6})Z27{n2HLM~*` zQUt}ZoU-Dq?MFWH{W!sq4}e9T-gRHkH@*7nwO8-Bb%?q?%|`iV&rt56C1U2;*c3rX zsN~M#Twt+8V=5tF1JZml@3p|=UD{E5&B)Iz!FE^Y@OHJVX4REIMqthJz!cbAu4yg0 zG#_x~agb5t@r|uLjy+Xg8{{UrQ@*5rB~IYzKfSzBZ8>D|#^?b7?ZUe)weqdSS_!~Z zXglsLRz{2r?;beH4v!Y*U{)v}iB=Eq>nk}K@5a^l&&Wu2LBZ!bV08TD5kr>UZ6orGz*F~;P!LCG)yI{<%W&W_ z8)9{@7V9blqO=cs#g~IAoLU?g-MZ%Hsg>PxJg42*Fn|upa^?S{>b&FG?4!SL@2zS} zkfODtwTV57qVAU7s1?;#>{Uf#k6JNWd$+WA)v7ABM~P9RRYL99dxwyCuKs?n=k>gv zfBchNmt6CE&gXp2`>ypece@P>Uk^_xqaRxS&> zrh{lksIIcG5g(rJ+b~JUZR|Qx7*iM9RY8tC z%A5aZs&%T@P|>2#^e~5hYR-IG@`IVMC6E-z$#YsX9Im9?28`f*Q1SFdp4mL<#Gvw! zH7LsUS6a?;MqSJd?9YiZI>4=!MR10&^QW1GlBTi3FxfZcc$=5{4@fXqBVQ`|g{8nE<$gwx` zpN8Z*!Zij(3hvyPhU|!|uVVAE8JAE^ompVD6%4e|6Jut_|JhedHg%!2(EN2qJtJz2 zTD`B?BPCmOk=8Vu%AI!g@eo}5!>-;t<}jCvHo@c1?(A%Nfm-vMBIN47VW&A)c&o{| z5q3Z5u|6I2xgc>!VNAL8BhBXt{^u+}xbW-xP*BK&P4~2-Enq)p1?|qkW|2`n6OHcH zUt_MoAtES2+EBp_lAZQMO?L?XbmZB_^hI!ac{of;y%r3*T%Jea<3LrXB8j9>t^3sF zOak`CZLQJx(?96ko`%8UU4`~SLMNX5y-4l(iOwClukdUKrdVeKK>fC(xn%Bg3#Zks zl-Pyb>RBC)a=OAYbosLqsPI@I1Jg9)(+NR|g#J|MA?{cDA*+=nKtcd>+|jK$dGtE|*9x zRnuQD65iA6tOF(0NWMuDXPd>+C)J)LDT&;nTfbUlMaAcTQvQS0kXmSDqcCH$&W=|; z)cC8K7q^&9O$Z%qDiYmW**(sD-yo<@_5~HCxim8 zJm_<)=6%BSp4W}#&j}AxJTH6P_Zax}TD_g^5u053? zKSU1PtkBzj0jJUhag@z{`f(tA%(!PbdRMIrcpxX9DnWL1=WA@lEJ`~qrQt@Umw-&b ztrB+U{fK;@0B15I>9yi&&|hlwZKQXH6{@iF=&3?@{^!9Q5>CRZP&W)m77VzNp6ZQ( z2~QS4`5$nQ$E6NeSAtL( zMr~)%YZ!KQ`5)N>I!q!@q`;A+V~a?8J%8Qz7b%n;-09fS2NgxgmvH!VHn|w@t_v#^ zoM|@dlA}IFbOAyxV!t)6Qh_#85zmSG#LA$5;BVR?U?KX-IT<^M&Pf?z@#e(jm z*E1&L0PmqCvMHNr1K^tM6?bsS4V++wq~Kc0b+746d0C<))X1A_miI}V_WY(MSFxmU zf-W8VsE}#~^O2xp)EjoF?V%m?ZTikXDYJ0)1kFV}M(IcZ_qs_BBpfRv{)^6ojY6Yr z1-7AVb-f(u+B~K_Df9T?&}JA`V))*HBOw`YSnY>8E^4SIJ4{6CT>*A<%ju!kakKDy z70KwPP=RkWx;Fu(mESMAcf!`o-c@qO|Cd?X@O3St75vhr_>pvjC`1sd;WU!q86%KL z`lWd1bpP+#fe|B1ayRRWcuJ9GkYWaBf&n1Hv|$(FkvochWZUZgDgmyf?i5@w{K+*z zhDWrs_ZpOrLzH1z3_uYgTj6G6AspE|JZ9MN6I3tj(R=APrqBIg=Nt7gv#NFY(P0m# zd8P03-q({`oZCSYQP|UZ4R;=bX`%L`oTBw+xu?T3g96E#YowurT%Z9f>qBTQm^p#@ z&ml0IAm9trI@Z6wHVKA!(kip|)B^!&N|`NJxbYK({q;?#KrJYRK--K~9KORS8WxXu zYI{8-^^Rho> z7R=JXw3BcZEfu_OeJuUbrel@m@_vcgNck(ffk|2*wxhS9tx>rFG2%cW#SxeJL{E{h{-6=wDs)jVv*%z9NH6T`m zYwv9S{JnEzbGUkJH~RC>b710W*hRT&>}`6i4YZ;>fia~}<(J3O{M9{&_NR{z_E0WD zkOr^IOYPvf>TYWX{gnW*2JOd&^5R01Zo%r?Er1F&y`=xeV34}CWq9_zZU~;cx>?M+ zmt~Ot1JJN-rrtB+(~Sy?znI!;;B5<|@f>^qs;40{nMrAWJ9ekNoxfpE(^KbY)zg^W zmEty!uQt#ib9v}HO@Baj86}*+TttrVQ@R#;BGp#p-CfY>h)G!zuQgNt(Ig*2;Ie&( z^X^Zb`!+SubaV28YkKrmnb8_*X{J&1((7syCjJ2Z$H4Qxwx_~!G|-o?oC&5|yf%R` zaK;(ki!`BPKZB*nM6#oQk!ice8~+{JvRvafsis!#D$hh7{}LzgeQGE&zdc6T;Plq6 zxke)_gRYMFY^oc+BE+!jpV6`YLm zpkF?~);KCD-Y5XTrCQ~J^!Fz*-66a|$N zy<;sknGBAQ0M9+>Sb+JM_t3(_!&ZDs160h@85a!A+9 zQ3t9Ds(RVZOuJMM5wh>ux_Dm{6Ynf3$-K~fejp$v2B&f?yAv}+{~^(r>fpGxKPd`m z#5`i%>D8fHBJaIMZvLo;EqY=^T!PNnsuy;sdm%i(4mxH$l-0Yl%0Iw~(bYs79=jQ3 zI)^Ulv8;g!85h5HlVttrQH*1+eFpQvT2FZ9bxd*i7Eq$(ChMa}g2@H@B~I-b%>Utx zdbC1;d~E(x@SgH+#{T8eh#`S;O%1MH_&xNlcZQ-?kWW6tk$8L5KM9{>*XT;3wG;z% zJb@E-`yG<)UK(mSJ}^~{A-4%w-D3-vnvPi&@g_oam@mlA%ZX{SyPx(_ zFD<>&U~t}Zuoj~Z2R}3kc@rM@sng=QM08KNvCI(by1*Fq5I%Rgx8=vBFu%M+BFUiL zF~spoE=vwsdow$6%$&on)6+$rQJ9SQ%F~46C=UM2d3c!=I{G!q$6x7Xj4E{ZQP5wS z?SlrEovsV5UYAc1q@_k3vuGJ=px$5m7=51Al-m?Taw=e0r(KREiDHKYXq<9?(1Ibg zwKN^bv=c^;yC`UC?`v_op`)DnnjhKS=e83Sh`Y{6=j8Go3Ud519>aF{T{Vv2JQ=fc zt~uj)UkVU(T;_TnGwcL1PsftkLYKWHd_le&S zwO%D+%T`ZT*&%l2G}fU6=e6dN86i#lMOiv7aA(w%(YI9xBn zJ{lBphc}S(G^3t+OM06hqf@D2bK#6CZszf`>tlh>_WOrI@Cq|n`CI?k&@G78r4@w*} z7!$aHb*`t$t}6B>9Lp1UP^=B|K#*j3eOaSP)*W6big(k1Jeus<((xaD)5M^@>ETVW z$Nit#0|D8ApsV}*cQUlfvx9C;_K$=VP|}M-YENRWt!*_#p@QN)i9|y3kb2WXhB0SZ z9rwjIseM;W=x_J4{Z~o%UqjAyIBU zneA5Py!v>SxBuCvAb=l7IZv&ah$#}QwTx||;N`kva`g{h74@&lG=wRjYC4&C*UnoK z&%Dxtd&L6fj{F)udtifSuEYdl=Mk^$l7qfD{8kaYLfA!%Qi%?!=sy9ab2Rpe-ijm! z)9Nx{4R>yy_rTVcH3Mr|2BrC^2K*DTJ(%I)6Yk~LJ7(vg8T4PKlPeHrc}z!A zTfQ;bH@YFL0^+d!2kGn^+~3pSvo^eYc=tTScRAiD*7tkX<5k7|x;Q459MvxdE2%1I z=KdZ*8?_~cVR20(v2S=uVm_-Ao)CNk#KFG;doOiW{MGGdwK1YzHfN46U~^mO$j<`= zy&N|@GIj)oGZ6L11oL6c-VeT`6>$%P>0C!-sA!v5awx5HKj$^WTW;U#LjAP zp5w!IBp0M%;A{(@MfwxA;_4FV05V1RMDZ_pU~1ipTpmp^-G~8P5RG(aY-d?N?)OM8 z86noqCO$11+`iO03yr6&V^90?qAG70sEEfD++PMpJOJ{6t*X+B>J`dsA!)j&`r$V< z{H*ZYW>a^Q51_%fw)&L?9dbU~jWrE3EP*HrR7u_M274=V@N<%-i2H0(Zh|w&-U&Tu)UmIQ*`WDY6B0^4pMIE3~xW_6yiFt*8lQdJ)wP zGT+z?8>2{gv<~uy0;p4nU=G#S0z0*A94gmEBlNGH;m`&7$JH&c$uE^-sY$xebn{24 ze5u!2Bbl9Gob@=O{~HbF&A7t;xE>nt#Xc#$APT=kbTz!CeUqMtg2K)&Yx|aIO}z$h z?I`&=rJ!RZyld%x{%UvwOH?q1))$nHebIKzj6V48M|W4)J;|e&hnNJ!p4Q5XIE(LR zEISJ6rvc3rMgem0@{&Hz+w}|I7JxJ5uN1PtMev~FA7ctsTW8TP$y!aDqfZ$zdd^YN9}@PGRF=hMte$c} z)3ECxGaZ)}7Z@Ecd7kbc>viVdh9uB&;x>oNb`APTCqiyq9C-NHL&a-`^NqzoMs7nH z9B;cH>a^31refnt*T7!u2lOfl2a<}ifkLM#SSz&nXgiw>S%a)1TObz%ek^)5u$QBD zC*j^UYhl!I9JW)<#j9VP<(UD7L?u=qmMjw~z#;U>ZW~Y}qr%JA99GQHhYOxRU zS)YfwLeS@V#d7E`(TMO)&*GLjn<#0WU|~PGtBrH+<~M6`eL~Rc8c%xTr)Y&5ZoR5U zk`{%hqs8jJA3iT%=LE7)u!(%%!5o4DeIT4SQzOmeg&hv%vBg%Tf8c_Vxoh}SHc$~i zD3QstWM4euRG`_Qe8f+_{UnriTJtwVNwMPT6^$PGuJ9i3V?%RG*1|+{>I+8xaLrgb z$y3HHfr*()=~{MbY{+qYrM!)K?8&bw?-wj2u7Q0!#O+%9M%XvuOmjlQ39F)vJNGpD z4wOV*7-Jq6K)FDCimJ5^_UesCaFm!$8L?Tzwq^X^h@Cr$9)<#CS~r4FdmN(0rDZXT ztj<_(A_g9<`=n86)eCCML4`h7%TD6o5&yrI-2)>Uuv!;~30&oL0@Q^VFH+uF8w>LY948U}9;p2(Hi z3G|++9erNM`}pJ_ckIjZhjWko{KVw3fc8x zZYflqtNtOp5AUvknZ81z7Wu22t%x>rzb8NaSSvEbH>>rzu%~e=zT7QG z?r>e9fVnc89gp;R#5WeQZhTCCYP0T;LJ5uE{e76~zdA3f4^_jlv`Ii+0 z)}k$K{~)V{w|ZPfIkDx-M~Pz%xehASN@@97!Alluv`$;FDBzd}tzdYTc~R=6dnI*h zYIkmTK(#!eZj9h#S5c5)rKid9vQhDD9A10N{TP!#$1s&#MtdS(iaK>k6@AHj5xWGq z%kp}=(KUyV+Gl!@s^Uwy*OB(OrL!l}s0w_@&59(X04?#)I?vd{2YvCR5_1rq(TfrZ z3klrcdQmf`8O5lv1DIme}p~K$V0aBdl;I{>C2@ zoL5%xDo;0c2> zG2ht*SHO&x+cPnvZE}Xh&lo;IDILJ>xz$VC+j)y3&;m4Tlm;8_A@}E#*5}HQ2lkK z+C*?-45Z=hg~H>Qo>+yfAqO;<&G;y?F5}7$I~jd%)Gog7G(s;Fh1+tFmD^bcEDcVb1aoxdwo4!x1V4G}6xU;rx+l&u7i$5u1)Qs@M^P_+E@tZ|dN znP;OcElR|X+U0DsC@omg0t1yS=7m^4wH-_?M)`@4i%WxiBYi>KLIBstAnCcnE zQ|I(nq*Dw3qhD0$_j1m>Ayi1TT3CK>8MzRKNjWJyu9#5GM&@C4+} zoV}Z24}DUZk&!dnb&Q+mS&O8$MOc1Lxcd-$E)&bT`Kt9!w)1W@f~GDF`~IugGi{Dy zRLt8X%@PNmyzr*SZZaIjd83xONzBnbv37NAZ~^ByjC!mSgE9L>q+4+xowBIa`0EB| z&A8hP#>odAc0!vFg5I183!F`R_YiPFU$tbdi*E~VRv2TZ&XkbT$2>c-xJ>*w zC-!~ax#BHXEMB$ZCj`6p%G zkho{7yWB*m1t3F#lA|KpwCIHnb&!ES;4o1!%_1im$7Ru-fa~Mo(H~ zw{C{AU~R{>k0I`EW|74O%gPVycAc#cOy?NYq5}k9>ZQEzds1%RBUCbcoP~foiR#$E z>NY3_YHII=YLIcH!Bv;9)}KsVu1nXt2)}mTb97(g9LU|oV)M6L-kX*gT63f9%k}vE#od3Km)w^$J5f!0q){i8(sTMd^QPyQ zkiF!(bM%KDE7+5~7p;>E!)o?;0g7`SXHm(SoubgCVdOd2WXU1+tDP|dt5aVh)Lz1> zt%WjK4Q#Eg%#P5AV_L^^{*k2Gi`&H!kJFZowc7Ui-HE;G5n)EnB1M&6C*Qc5o@jD# zsJC&4Ql9C7E1q?Ux3O)*#wy5K&4PP{zh$>^ItVzXXp6@}8ocf3d01Pb!sQc39zxj} zv*(uv)?NW9A?c%|d1csFUxaW@9*;|sS?>nFO8#50A*El^ItNO2H>kKs*BbBFzpuW{ zqq|ea13)ncgOEw0sL;aGZ?RI&>+pVXHobYe{pjF4h;W)bmsqBUKlR&J-T7l^7`#d` z!CU%Auf2a-j4Dtf+WXkHDhEmZMcX-V+2eIst9^SFYQFtE;79x1ON9ZWuUnL?jZXAF zEz0O7mY?&Vkt`m|Yd?QaHv|-|Y=}z){>`{%o|`CrvaXKDDEg0R%={zkK=~E1eLeWx z?l!XJ;(5WB+4_Mm*Rxk9D<~Ch=6{Tc;>tTMCV@Dvdo4;fHQBE+F9EdRHVIKo_*9Fe zBWI0lgdoEvEYExo69jpfP6TggIjjAZK2`93`sLiP&kxa9F>5Nb*+#M9WR050mWF-z zt37fO?)6v?7v+aoIFR~tQoNp%mvr>`q+q!y*Ym-6UoTuYex7~EaO!h>Zw@P6gCqR2 z+#6OFgGZ3QG_1}758YmM0agd`voo5l35~7yv!z@jPoxweSC**%%qq?gffzxRu}0@C z8(Kz)@bK*MqK1XXB?{mKVBYx2q#>knp9G8(fIeST?kp*gOh^V>0C%(e&6H3I;2^O$aP2`Ig zt$nud3FyZv931}wvkZdJm3~yIJ|eL@6`%3KeJy_ZX%d*2p7MT6K4spUgBKaD#&}tB zq5QaU`ty~q7@(odTgGve<8rzCBIZ#&ooQ(ZU7O>zUlYP<(!fi<#sF62s4oXVt`9;C%YvXlFTq9relMICAKViU1t-zHK~nT}HvFc({ZX zY@u>u*qfZ#e=&H_DxB>QP|h2e<1Y9nxrc@)Xec?!*guE|@eVl@@^!}7_;3Y)@#yoOY{D z+X&%wDnKSLo6;jTBEG=%hoICZQO(T;|^_Vbo0Ltjm&m%;3+%A&p$B?=8$z zMo{M`qP67yG=2(O*LG#2udfP*o|1OcB@=%Xc%zwz2K^Q!@JiJzh3p)oLn{@AB9iFI3KTlx_&yeXZFX~CL}ZV};j=^{1c}Zd{I?mmSTnk&@+jQF(Cwq{b z_Ds>yV#t>&n%6>D=BeMp$=ld=-x+(~7vd0bcil;(Jkzw1UDb8&rukvOa_b?7t&Mw1 z0Ak^S^{1Jl9&RM{3#AZh#(rVvKR;ozz}@W${EmhQt6Hzcg~Z?Q@*gW6_8nwTY0=0p z?-f68oq@Gn77z~yJ#qI6X2c%TwEsx!iDhA%6(dO3|E@{hcdHwQw~5)7&B2Qpp9AiX zA6rnmKUbDVpk?>A<{NR!Vmqt{=D14h$0-9a&d+HrnU-t`pWH@XRU-a);(G00MDQw+!cEt@3lpq*Dp(Y5l2s+wZA13b^Bhr>|*`cD*%5TcXz_k8~r zo{SJwGsZ3{R`OKMkQ1M$RJ<0Kz5)^vPXlobHtIN=Yjr%s6&A|!E$nt^AoNnW)dEZO zH#>D+9M*ugs*OaimkKiQDL?i9_XYD@8W}B%Qob{^^MGZ0X+m2r|LpRiK;*cC+O-#1 zqqXGkK^YZu0dbV6s_wpm_=+%}!!_WVIHi1ssJ}`gYZgmb1h&nZzmbN8SaAeQXwy{v zg8ABs-}Cmcs~KG)-Y_DU6IqAN2~&+s_C?fzMYClg;RwL#X7?{?`33LJ8Fx<_7Q7J+ zk)6I@x43XTnZrQ-)=;>K%xS%kUCO(BbBLFt^5;o+yhsPPkR=t}<4x1x@HKZ4hKNVg z0y$bV{>2GW!Z`z+9j@dziiyG8!Y=Yp=%nc0!-hTS4;S-A+1{1>VFfd`6tOwr!mZd3 zQ!(~*%NEe$=!JOB{D>A9&e-Umamp?Z!(j$FL_m2mVj=8gEOGnth9lR6Rqt!Dj>Ay6 zWyT&4NFCIg&6;3_v33GeM84J>woDm%{r9NRDunp933A~%uJcT=W(4^s7=(_O<}khE z=nK-RvUtA6aI|_{pEhkp*)l1hkrFwPeY3eEjvwd?P4MQTfa-41>jg)|lE zKg{}8x245VC25$6xSEzF>cMpC#vqP`CT#jp{dJsPnhHX_Khj-p zocr&}pI(Pe!Kzane;ppWTO&l2a;K-`%B7MzO%~(qXMeUX>;HaTsRKI^t*~hx3@oQC z%sbcV}sp3tBS42RgDt^DuQ)J2mPFh zoS<#?LAjErqvR;8&N$)V$3ZOC_E<7ndKZ+%tU-UKWn=rx8XM)*;9Z65S>?G$fVLFs zIqSAbRP1o{N)yDhOnEB>Yxx6Kndd+%?9P@P31%tk+FxtM-rA1})^oW-o)sY=4W=D3 zUDc%u9!}k(q$wlBIByq>ka2RyCFz7e@cM}Yhi$;$?EQ+PO7IRm`CaNj=R-r}34!hc zbYJXH^bxVU5)W-R7V9+yGJ&SYBehMYDemR!b>Zzgy1OGbM&Al$d6$0I&Nuxg5~6T= z6|^qowoMeNuQ1^WnNy%IDX=^ng`w=nm1br%>K2GuXNzlb&g8<+4psl@^RnLBfFe!uvX@mTM}3OZ$UFG ztm?qHu50 zUq>(UJ3HH%v=)ZE9GAcsXSD~@%imwVDFxsKclO%n?aL8|87;G~E`_W4dh~V9qx71p zvRc1Tod2mb$;vlIp(dA};&C{(^Lf+A&iTf-NAta-$tXrpOk9NBFA zn!9V7`4!J(nrSJi**#ME_Ey&@E#VMb>v;kA2~ode&o~QF!Y^5ld{jyR2hSsH$JdJd z?Y7fX1-K#|!lc~P$HA-N!-erVX9a$DhX&D-X1jh;+i7tNh(E;Wxs7N#2n8oqVzspf zPnh|7sceEcg5W#)E&Nv$Z47OLuAI?8s}mo`ADUq^pr@dznvm*%9toRj3ifJVryI*j zRaUBu_^RU>;7RHmHft+g+6(sjC-Z9TO5?3IKp%IUVIO8bL77(<$`7D<0ctLt9uAT8 zryv6N`XW6z87$LD^W*N5Mf33NVhsaT2(y55`ZKaM`A+(_xDM*`q>eAcJ-uBTfc45L z>H;^jLPD=Kwxid{B5qw@QhfpG$O-p*GsIwO!S>LIE0vQ;)Ip1X0aL75FB}mjJWVck zYg*ea#6|o2a)KzOIQgR&V7qlKx}513GH)q1&71ezmgFF4bK+Chxs2j3~!>m#+%-+Ja%%6YVMrzg>~jFee&OKr_o= zJ-qRh+)bkV{LSQG)IL(xmTv_eCAURZd<73Z42A9-d=;R*snvG=7$i;kR-LFD+;0aS zsnD#K-NEpnk5MBq-@o3Tfdgn$w3eNkHNrPUj5 zK-s4~q3uS;N8?w@3_Uf5?Il*;nWeHk?QCT4i_nc|Mxc&f<}Mttq-Q2_8Lb6U&`eY4 z1(CCn46Y7CukT`LaIe=mEmnhbAq=C!p`a`eMzkf`KK_{UP}_p0W-#aAhjO_Y9qY_SV((Pi=wLqf8?giB<|`gSN)S+*_?2$KfUsK~h%r4vI^_j#V8 z!J9GB_`(>RRB7Sk>^emKW=aQoa;cRF4xXofhJx(?f9AgAU?Ok!Qu-X=d5 zrBWnl8$mItio0pB&9vL|yi6b7qFajuI+mYcg_J(IA50`RHk!fH2(HXQKA?sW)guXdtLF`oC<<(2DA^c?wmDapF z2N|4g7xYAZglamkA&%@%<)hF0zWxRBDfXOM%zOpk)nXxv9Yh=c!^hY|tcK6&BZ26i zK0cb@zBVnT;t}{|nZmIc+r&nF5nAedVc+oEy%*+llrRd%)_7uWY!8k1rd8qEArjkE z_c(5}GuO!IX&o<>tq_bt>#H;8q3o%^77Vxw2%i<+xchBw#lS1r6|D_tO_!?cw(B|T zVlilY1T&g|k*-4-cao6sR^1}!c@cA+NI0>RMr61D@)dDFRgjoK(m~!yu&Vo>h;)e( zfSPJ#b3XDCov$mQId>&W=FO*ADS6P<`>{5ksiccQeW`V%sq{ldh;2bab#D24d}o3n$#FZ8Akzk zH(WQ*u>?d3KUj?abrg2tfG5#%Qm8gM!%>Wc$R+6rKTg4E6ZX z(knE~pG%SaFv(muGQW~P#yc_PJ9o4u_mY?&Fsf(6HYb~)zcS|FNZ1>9Xm?0 zzSK%`=#@te8j}yfUS`zLs9F!;CSOSw(X*+_p6o-2MY2ZzSz%=!2ol);lDF{ZDK4d;bWgY%c-4~OyjxENXB=G(e~z5Jul;yO zMWBlrpKWu_i(5+zyHpL`Io(O_c?buLF;NK9Rg>j4C4|L+Z(TD;cCGEQZtLx(nt(Cgs@C9iaf|ALJ25x7w;gtZBdVcVFClMJ#UBW`n_Zk( zzI58WJhwbQ@;@)sC_BVo_+T0~@=5xNDGrg%QlmZ$gU%E; zMsx&dH4^nmH)=$N5dvuCLhl;w(o%I4qf^QP%yp@*g>2V6+-SZ?D{`i&L-r(<(VJ5E z!M;=Nh)({z?!8epe){VR5?u*{0b+WAUi9g`v}!o-0n1o@hI_R64e1F<_%8MyJ&6-> zZxKU|rRBtrAR0-|n;9bbUwF54E)IScmd)s2LUsJZZgmUB9g@F@IXyoAEW9swpc`hh z5U!CSnmCNLQxx`J0EG+R*ecLH{!Q^%aw4lw?3#-lSeGF2Z~nV_vll}j!oSt#WhJT9 zgNVAtI&>f^T6#7}+7q-w{uu_e*Qep%pvK%w@s>g5F2{n6h@9}Di3PhzKHDx2=n!dF zA?Q628?A1XRnHo*qU1ZnJp6b$MX`7%ja9&eLr;qoye1b_7>@rnQc=i;)6wC9h%(7U z-r)NQyj%CgSAtOjzrPXdwfmN`!Qg=Cq&}>kSWJ(8aD35EWW6l8NfQhgRp(UIY)r&v64hi|U{6gVjz{M{Vo5c;2>sqi)Q;T&f zAgTC7cW<=%J`A3*rPCGZsJ9S_cMJVRQ_Am4PN1iyPV`ShQRG3}G715|23?gF&Cfnl zcQ9TVs;>#YV;Q7bPd;ya0hXjF6dmbH=sG&LH9{P-cY-#5;4zX{z@kgow@YIsy=(2E zPquoE{KZSS{nUgaz*w7VSGU+;y6E6Ts&si6uVhO#M!YXFc38__B(YkKlI}=EyX z&F_zYP0iLt+~II%+%9Wm>P}CiJ%9Zpm~UtbQ~21L0c}0t>>y%`mPq?N%3?yX<+e=; zPjS;4O_N$%XWP~c(-Va7OdA`$!A2hQdqfX|-D&Zh7ja1a_8t%KA!_tp;g|c!Gv;;o zxzqq@qAuz)OV=8hm;G6k_vhE)W;`=-gj%umLu~&2LN~~3S!+O3pd6B|m&CEz_kAO8 z&Kn9{=kMhv7;gXJyr(Rj4xo}IuArpsYKV!N?MPri=|@RP;O^IV^N{E4|&Tp7ffkS_9gTDpyaAo#sv**c7UD^sr+i8(U{5{YRVG zKR1v=h}qT<{qT0c<7Utt$Tbx>UGLNPa9G&kUg7rXi93wql6ks5Fj*}qP$Tvq&_yLX zkM0op;xlT>ipzhr*}|!o_i8h$X?e?^QzXeB=yFjQA-wR|->v?8)1K4OE06q^s;jea zCTw|~V86HeJ6xXBOB@s7k4O79N;9&fdWwGZ(k`dU$80ZcvHhW{VfoE3x#576>~X*R zGh}_gF!}yB_3#%1s~U>KhfbGryZb2lub4AeL0O!2U8=(H0W6k87M{x2^lRnZI%_1l zf3EylPx-wYS05h^T+t7-(=0~&)IGik{Ed-Bt7i-p*#rYv7?z+Yx`DHUq8-5F$4dPO zTVuSBF9T&<>Nk`D`fuOQa}}EK4o?i4Um5@LRggpK144-DxFqCPt)wx z`RR)_+|^KCU?Sfvr+y%p@0VsxrZ&+6!P=(k=0jh9aUymg({JUyYkZH|Da>AF-=kPyP=xOjB#- zGG%vFpd^s>fX~*X!upcvccA>#gd8Y(Q^CE5m;M=|x{i|~!$G?iOe@qUg*m@V-O)f^(us&plauK76V_V~ArGWq6!>k}`&_FyTFe2$@t z#89ccD1@&uXcv6e06{s)(@bB6Lw{<{=x#Ypr<{)UxN4t;P6JNB91)&`sF=05$#d8w zI@MxC`^9(dPZwI08vLiO0@*>&u4+){{O7BhcB$?TWV~b={0$&-i~NZx!UF>AnV}jG z72ctbaXaw59OOygW*QRzrg4^{nCypDpZw`F%XA7QT3H+W6(gZTtTl6n%~}0TM5oG zT~Q#v&%;};GqtAsMvR%xuq;%~`*t#eJUFOv|XJi^hx)!{j&g|O{h8bokJ zNBqOdp|Z)bjY%53Ok<_<*19${J{&+D_#i{2Mlm9)!2azQqJSjye6-6D~8>mOczE0?uOrY=>Dr^ zr!^FZcyA-<7Jc$=4Ez-^nFh7r2=;21C&|BKf^r=o&(X(BQ$S0-A^`UKH~eWz{OGW^szp3?RL=`Bp2|AzI4v~ZHRu9iU(#fdAzsvwtF%opLQNzhF+hy3#8?L zNHrz@wEey)8$k6UL;%RRzqp|^B2U)MpJ@KOIX%4x)s@1^ryL?L!XW8937?*H#Fhcb zw-&_YbA-gwdvn{w-%?3EPvpj7TF%V?BdY8G)k)2*=M(OG?8LXJ1bp;<ZhjxR!Ds7)5u>@h>oI_wQeTik2z*|2Aj(OICZAz$aRp zc0k85uC~|0`IiIs@^qW`WFF{D;`#sBVH6PY38gok73$;wrmo8dOOycgeOekDcVrg~fobYrt8{Bo45j0euf$WCpa(BdS3v%>%l z=s>$}04*rg`^5*ox^ygNApq#2Zd~k+i~CE>1CUa%&34N&gTFAm|4Rxhc{|!1b#KIk!j3J2R&(6=5_%oqQ~GaX#|nb}4`1&cNLBnl{>zFK zZYqSX6$)8pbIDE_l^wTGD%Z-M*C?*+$jDA2``Y_jS!IP>^OAk-adEFZzQ_Ch{eHgR z-#@?ql6#$VUg!0C?&sq<=R2|&`k${SF3F(I~B`Yah;ldcgOA z0FV!Iz$M&|DG?sV#~IC{3asQ|+f1?(5f~xa0x)x&0WgqTb`ass8Mkh2y3S<`eg>NQ zFKh0<7khBQ8xvSJ4Up+(lWsi$NOjU`XUHZ1w6pX5QTnyBtpI>cWa~x^lp4}x9L_j_ z8sdv*_&$Kd*!2IB>$Iv~4a@+XHQV%O7jc&hGQGYi0eqOw|KQgEa?TC_jYIzzzxF(g zV64NlY@mu?+I@V_{w5N)Jv)8x^>AAY0Jd4Uslhluq63a0>;U|_3le9z!~ZU^hF&i4 z^dp27U{^9oaQ0ITq|3z-j)7iv;8PE%&f4%D(G|Jrs)2rIPdFw$sCo(mPy!nb4eqP| z8*nIMp=R`)EfIJ-I1nq^Yu0n(?3U*DilL zg#--H3<&Hp+Fi6=oEGUAcqm|R{-3)G%l)-7J>~2_w z4QSA73{YL3mVo2+kEbzY0PS=MxRU$WmB>p#_dQ#DTKN@qk_3wQ?)Y%jFe0TgtHElb za7$*#@sxZoWU|VkNnfI6?2N0nkpDT<)+NuP>Q%!Tf=-1(JXpxDr0M}#2kiYE+55}) zvE}~(*L^q0#GX+dj{!UEvYnz%%sMz=&@8a2(K0`zI$Zfnw9*XA&s)cC7F%3G!}9*W|0KD{874 z!lCTr+Af>fZiEE_4;qG&s>m&Kt^fcfxnL;$4}i~k{})kb6O8U8G-^aJYdO}9-@|?c zMxO=%K+J`Sd$DJry)W4W+y=W%KjVkwawouF?=!aF>fWSIy$s%v}!Y?arD zYk6@OZ0a6n$Rn}%H?S6f2dKGmXNm=G$PLKoYqROzZRC#HJ6F(=Coj9g|8=h914}n5 zZt-~5OwWIPHSUIrBH;!!$r+0LSsiEz2JEly)cD$h*@Qg7Y51(^3x37YZLjyp-T0B;b5pN#*b>KL+txb;jwi_jebB{{`7xhc*yLS}Z z^)xyVpN$plcXD9mUPW7_c1#fVzUgi6Ep*O`xY%)gWCCoQYyNn3gZS))TbrZb_L-Wa zTP_17raEZ>d%4+ge_uS;7S2~H#I0Ltk1X)G8hU5B$H$+13$Bul_YA26L67XLr);LO zTP;6*B}H)pARCCpv$a?Fnm;hxhR<6_^hUi!c`G(q+?@6Q@T|lS7sG14$KKO0unW9V z2BG_{;lEX5R&(itD?j0Q2VAO^*bmqoJ^-BEX-{r2#{5^qo6=6vtLD-_8+L*gXLiRk zPG2!mc8UgLAajOA2bt*sF+d;QlFk`{a%P@>aKNu5l%vOG)s*!IV5iUEP`!HI5TSJG|9>CtAk|AxLxe`51!t&Ya5G z@$529KbFw)*q)hmYQ?XZ?C$}fIANk*jNK+s1OV6xF$`>Pf~)>{qfV`3SZ&%T9kRFn zqy9F=a-7E!a)h2YgI(Ls8B<*nKDKl$UB1)2+i`sY15`~$ox$Mp$o9XXzRRD-%h(Va zWWGtp)C~^`#_m?d8c^rWa{E* zJX&PvdOpV)6umo?a*Cov!GyZcA9bFQbBdlX0|Hyedr)EZDd`43TWxrJlE_eFvI56x zn4@0|71~^l3ZZbl^4TNm(Aoc>>jYt!^DZ%*LG9aZaOxFs(qaetC}v+S9yvX21a4l$JvBpYEAOpbOp1 z@HYGmOR;yOA@|W6nE-^Inh=Kr?C}pnAAoIDXB@#?re17%HM?oY%dF+p`AX^#-QwzR zHwT=*!fhRVRM*K@&pI4k_82L z8M1Twq2>RViZ@@@l>953G9Z-MRNr@So6U2Hp|^*t)@4Wb(Cf3%+rRgGC@8|ch-P>c zU|YdIf$|)FyY>|C%XKG3>>lgLuKM$gO2=K&iry1%=HaM?3s7ba=T;3PjytIcgY#AMDT_mZXmgu`^$@p=1*&V-3ZPtW`LNsK%J@}DL@{C-~p z$O|*~WSah)DiR)-54?3VMth%9fS8hf6Y5oP1Z?-&Hp~5gMhpa@02lRI!key|bG-WW z;lV=J-`e}B&bP?4X#BZ1JEZ576h#zjfplMv^`r*r2{g)*M-~X_}ji7 zZUTs=agNT2UJPD+2L~oJPz|w6Yw_-OtP13gq~KNbY|ae+NJdA!;gB<}?mPzMnW`15A~QWAa$9$Tp* zWy;rD-iR$yNZjO;&ph)PDH^9@?4-VoujSX@(gu|Bk>R_1f-kCx z(F?!FH~FT*n)mtzUZ*p%s0mP#@jSk_l|nx46{;s-iv{?vaYfMD%PcGPDpH86kmA-M z_GrUd2s%X`!~6Bcu`wL6H3p3hg8)FG*))eN&c1tK82gki;ZP2Rf23JNm$oFwyvBxb z(BbiXS{;_LnkW^uTJ=U~FqxWg`ouutCjaC=rCep%i;j|k&n|d|BfK|C>$EAWolUDu zQxYXZztd#$P8ZBRiJ^LfZ)mJJWx)ZlX5vrYoUF=g{HpXV5UGK80vxlkC;ebRe^-N@ z`Wk6wqu~6^{KdUim*1tKq}L7O^~krZdP!B*aWocof1NOMPV=jqcWf{K;bOK96tbVC zQR!fqv61aPzZj>3iVNLxn(F$5p2OL>)Bv5KN8D7P$uV{d6F8hBqHm?ZcQ4W@C3Dtc z?mId6rPf1lZA`24^Sl8wULCE%4&xQc7>_jhKKm3*#8wLP}nJ~TLzV+}%+O|ZF!59?msorSB z{WD34um+5>WBXUJ(|TH_X4`qoAh2^&n|91KtE$4mm~K5-?}~%+kPi?TDGJgj?KE!O z0X(TwE!-LSwfH6B-Rr|5J3n&JtO=THbMeNfPx5b*>FW6 zSjGwi5j3OnI(CN=&KESP+C&Iw13u-~UgL)q!G3En>an=CoU&~B-E-|$bb5{@(iMFtI#!Ent9aJi3Bu>rPYmT3-Xl)Jqbt(E4t1Lz=k3)NXMG`+Z+YFR z?PGXp0lJ8jSsVxv*G!1jI6`iib|m1-|E>2jbL)LsKk6B zC-u!{$T{m4$!?_f{ChEmYRJz0Vbk-G3Ep$*S{!y_>u8&9#_$}~qSU>Q#0QvOsUE)9 z(*UIDgjbjCdb0h}zfJ(F$8hEaJ{LsM4$OUYyDJ=$)4o-Qxv}*)pRxuQ&*pZ@+lW*B zGmnq9%8FBew8F?o^+H$1)IK0Df&sB-mGB}fe+O)KJlEn66g~pHf|tK@7Zc9^Z>FB) zZo-QnPzsq;Tvi9zf0p5*p%M0-zsFoZRcNaU(Vc``1H@xC2H|JwFzQeebySRh~hg!+^a~}Lb zNr&@#1B$^4KS>@rmwdecF}22n7h3OhlPE}!+D%9I0PiNcuFY$Ozy;4Ke~Je#^mBG;ivrBejS z-(B4^zc6Tf-m>P+DLVAdZ&bBPWgE9vcArb?sD02^mkzrMWGS+*g0CL_tajnya@5&a zmFg{Es@aC_3SE-JARAY-cXv3bLUue2UEzy6>HhvAW6ke5O8S^zI6Cp6eJbn8^>~m6 z?Qt8u#tkZ^Dqn||8!K~YYTLxVlNg!k<|i$z!CcU0@ftp6`mBbyllG*{jW5JByt+l-yQ`U81jLd3OCBa%cTU z+HOVNDBrbb-~ToZfn1Lp2`&#-KB>v3mJ+{^=pkSgX_&cKWK8rM9aU1aRsN%mHQnI= zNqekeS-XmN)2NN5>K#SjCOTqR7Ujn5pn6AULg`W)nh`vtP6?Q>QFcT~qqlC>3M@ zm-ec5x%#|3Ucnm1k(DQx^R@0J@kv%pcLRTa(pYJ66E4y4SzLQqo?D*Y>&Z4yQxZx% z7wGLNZSwR+tu+KZvA#aDjAOK0(RGOu|2WBOTj&p1;4zu^emTw6L^&=yrIpRuT$K6o zQln;nZ9MfMET+-J^HkZC!)?LKC6N!_e|G?us9W|@>c7CO-qOV z%ZM?-+DtDGjpLclOOTTs#5dW_U+6aR>m~{4WP<7!^w$quwL^2x9O)KityAKWe7hVM ze?|kHM<>rJT&Tz#mv6iml=Wfmn%}(?&Kn*w+r#~V2N)UiPG>I>EtIh`ld3}cSo+7U zeo4n=7PdT-w9)d413QDL$xNpp-4|nbQ7Y|lgTv$)Xkqlsx6qkay^KJ@&{br?h&k@Y zFKt@^HGNmcRaWWuY*CpN_bb)1c#{_pp|0vih~P@G?{3kLU3&H6@A9U&Lfoz_?k>47 zFc8QK%!>5X$8GDSAy1Mk#mH`hLND+!*4;&Tm63^^80-L^TVOKH+LdBH74SY z3WEM^eskvFpvFd5U0ONUjRht5<@SRhdGKy8!FvPl0|rV-uy{_dhY6#EB7v#Q-Sg?Jm8Y_ z;P~jldQ2fTAD6utTFIDx72a?>NKhT}eYHKg>eN)$@^@!;n&kTmvkv8kqSjf49C#gR z2KSBA6j7&$pxh5_$g4=-ppI+Z4zy(qOG$LA%1NjLMHC^QSp6eXaf6{G34AVUpN$G< z8h$gJ54>@jqH_=X<{5N%0G9&9I91`Z#kE3(t9dqzWfy3*l#B-l!RuSMZE9;g;8TVS zDhi^d26Ub_c*d`9Ftmp6AM6qLyGU-;jSyUC(UXy&F)j=p@=HN;Dg!mStUTLQJQ1fz z!DW|~Q{DxBrJ6{Q>lQ=Fss6M&0_IeVg$m=Iry^o{jRMDy-KmO2v5#pLmkKrP zC6tdx<>Y$!>feGl;6e5ft#j9U*a(aWEUFr%$jZDm>Pbg@`wW9usiPDxk^v^vjl#u{ zcO#T=#M5ANyw%T#-ar8{G#|pns+$qfg!NOkf%i@=2xLlgu z;PCx0BUkBmA78nLT`Rd-`_ZhTx|3F*3f^iS9qhO*Zw+cLGU1cLq? zMZ6$-Lqb0gE-dPx9p5XKJ$fxH`J?=9H^(nqty^zeg~<$3VyArF$7vl;wP5eBN|F2B zLu{7qvM#**t{lzMb#bp!t69(_3Z0a@mqN1Rj$Vurb!$fEs?G)c!r@4;Ds-*%iuWef zad&IcR%kHK_VIQ|;6f0Q=YHSOiDiXMX6i@8K)oLE&(zkv6)UXjX=hJBu}$QF@=SnU z6K^-?d)ZBHYF}8nWtaR7O{)H|L`%Hu<6=Wi8*EYQ*C0LZmgmr)Z+BP5v4VVQgeNGf zudPTYv(h-G2ODV@C5-QXkOqNWcnaoV?qMob*1}N`7`{+uac19jqRWP z5IGOo&fM*C|5u`a#6$?fPQx6IgHoF}8`3_m3i!w!`Kgln?Ym&GyAl&`l-{SVtlGp6 zqrxE_$K6^%YFnMk#@W|!<=2qws1up7k&3Wpc~h`qP6c+dH7S*Rz)|BHEW|Zf>%kk{8tB;^kH2CBq4_f)hV?#(^y& zop$!o*52aaE$p&G6LOJjmE`=mxMEUq%&Xnhsye^W%fsD_naS`$lGw%X{I*TGTp9*9 za58Yar}4_bUkN0J(8R`lM&AIY6TJ*8BL|ih!_Q~KZOkHsY$ygBuiAk6EYL{?3rHhuU4HU=$ei);mDdcKTqm>eOw|cNQJ= ze8S6cjZmtj)byOc>jCVS=Rw(}#?>TBH)>i~vUce1_uIBgRwY=O>^lySm&@<0in`^ z6$-CU`BU(!{sZ?pHYF%vN3A(Qy%h&~1o|%zudUUOBu5B_gpzT+V_ufTbu700$1ECl z({J4}6IcJ_7C?sJ(slVldDdri?P*u{kJr*OW$$FO5RY7KhI70c{hmk*Ere2V^IyZ* zjbg?75MndlPS4y4;i(3$X-`VL2FF)f5H&Lt^Fb4u-}ag?Nk&%P3#&1_4LQb$LPX6) z*K=+(4z@E9Z3;clTCOOCV0a^xR-cL5Iu3!YzF(GN;BfuetF+t`va0x=EtKY-5S)7n zkVEkqqmn`qNFE#cy9$!r?8o5K&xIa2X(P1y_?aq2|wbx)(<-iW+=^TgwHpCALl z*lAZwH%-RoF66X9`GdM!N6W9RUrWrDh6ZEj!#@j>VW3-T!_ZZnqj!d76`J^2B5knyb&46hj!l^cMD zv2u33wZ*3G$J8Crec}Q0%S7L_71$QwzHnH#-(yd|g&Sv|h}&!1^CbNpq`jF(eht1@ z?U&BHI?DVuscy9}ECaEnpmBvUc9T_`0X;sQyN(%46M-)85R4&(Uapd{CTlt6pwy=*#$noY2Bk$Ok7Kh5mUH@xVMKu>A@ir-2VJKP1o^e&;i068O;OYw`C3r^m!g&2iBH!2S%Kv4Uq*5q*c2E zKKN+E!K(s(Qe-WPGmKYl|8jJY6rty&rR|OQR~c0fZ+RaOvf>+$n^>TeJs5p;9CESp(@8Y=3pGWxlMPAir zU(#S)G@;T8o03Gmcq`XH^uEBp5&Zt)i>&1nFEO`fKZP{6^$`0aKpC6|V!ZuFh$D5_ zg~ckOY%8IHoya5m4gamSO%~{q^LNUwV&YzHUXkt3v9Xx-hJo8TyvQ3B-<{uT*(^YSo$!E)Lu8M!uM?WSlIA1*}iqM?;a8?lVv)Ob1;(t9Zt;}AHaKSRPTA`H&9^U2 zU5<;lI3xysjJpNZOl|+Y6Ya9nfJI4(cT4X=u0!ntJji|lW00ch6)tS8g2t@beqt&{ zeh!yEQT$Gap!vRp^gy|3$0m8stw;65S%1XA`@q{aoTLqvP7}dwmrZ;`Z*TAK+|voD zqL5$I@@->PjHj%c8m4EAK^Su)wn+5X<7p=|RpKJ}GhB5gbw7IW+GL|Bcy+pLvMhju zSiADa^VG?V5D{SKxI0@B`Rb!?(D1>Bw&L*A0e4^3A%%o-f~{w4BRRdi7)r3kCA#Dt zDOE_lrKFP~wTXX)uY>vN?O>IP#j1BDB|Mt?f9lG$uU7El4i%rHPcX302I&DJ z@d|Q$Q={ZB0*!bCUAsB&RC`Mw?o^=40v?NjQ-Csx<>V3aYq7FT7>}JzI%UJF$M7kG z+>_KwgwwKc%0`pwen(wv%`@_P%r+a+b1}>WJ&65NPTYTW5ZmCjn%SQ=%i&{I!)5U4 zp*Nre*<_wSc>@jb{$~@W3A3@iORknkK}P+a7>Me^;e{+QhMxpG)7trKhmwP$MxwH3 zEd;=T5=-$kodxCpUMIKgtIT4K>5>!oEQ~;0I*Dp7jx({swu`)yKZN)fHmkp?ZE0TX z5}K=c%%OgHBsw;Wm4^Nj+p(gq3;zulX_?;&>Kg|vUv#bd`aQ!HMih<_NFh<%#X{bF zMx75ifhF(gV=u4;xx99-*I#wLlyIdW@|0cIokHKQUR+oYu`M+PZ0h$CTlks@RwU>e zhuUm`Tw%+eHB;2{-I{6-QCk`0kL(D$jHKq=sE71{ zfx4}xys{Vh0uF^R6n#})ECQy zbr)~gX_cbC{&b%ZH4f1zoU07ic&PAUw=CQ|eAF~c)J{Fot4V?3Pz2=@raC`g`iljJ zqvITFL03koeP#Y)j(-@R_g!_a!7<@t&le(sxO~DTXnUbOi;!cZL;m^k1@`qNWbV5+ zF4o|9)EyGpWvX*B?8 z-`^7{bW=r(fNmm~L!m~~xX5UZNo*d~XW2VwK?*K2)a>3=KDo(LmB3Q~%gF|H#T|7< zt`8jYUOZ*J)d+D(8#^|Oy4Aym?1PhZGT~E3YD?x5(4mF85lFr23(Ir}U*!`hB4b>< z-n8j*r#Zi@=<*oKTlJW8Ij3sbd94AvIy$3sYsoaeb861zTRPjaKt0o`yl?E{W#}+m@_pg8I~FCf$$2h^b2n zQ9-<>lN~<%vefyCh&%GSn;?FN!8`D(jofUO)3*A~qnnMxLs&<&Eg{7xQipYHMu3oQ zo;G&U7t3oqYIt3To2B@M&U|*xxR0Q@%f%O`1|C-@*Uz+IceWzpp|UvodeB8X!xn2Z zMEk#+7mir`5s8nfnr@sPw#UkyzkrsDjZ+A1aC2huTTt_BdR&i>+yUO=)u)I*8UeOq zQsf$mN%=YV0c{YM|F9eP$mWTqZXpRjhnWig{02e$798d!gc-=Jb~$=}$d>UdXa`zL zr|zS2L)6e+qK6YY$trbBXZ5RDVDrZ6Ziy7?>Y>?ro}q4!oy}50ellwb^24}$XzuI0 zm1{4W?zb2Z?mZ5#Egve}DX$Gf@S(dlz;v$Ey(Th7LGrEt=CwlPno8HUbO?1kDh+|zV)fJ>9+>(MA7Lv*~Fdh;?os|6~EF;ErJz= zS};vXz}ie7_Nq>}jwzZTl^x@P#E#G=)`@>SqZbt4-Di;UW_$Y(q zp64Yc6d9U1%C6~GB4P%GE4N~5G7G%y#M~?>ldsY%`{(>q9EB}<=p)fVqsylnUg+GC zu}n&R8PDnIxHZ?aq766%{`m>+ZVg5$2zBWU? z^C#JEd4#JJ#EuQI*P2DGGMyG%yScJr*{8(=CV8*DVCgr*jS_3O-NzjljWaMAlg*Z` z`!s(;^;=nwvbQQ|7Hy&(2>L_UTIEx9s_ZO5R?}tF5}o4`__tLnU$#DaXaVk_YGyAY9T~@o zxuX=MQ`!eIW8mF(;3v-6!K4Ji(GNd9q7PF7eE)sibdt5eedDW^1Hb zUHsLT@;lM~-)}0=bkl=|rSc}up56zdv%QLd+xRLk0VQEom82lCt}3zf>cecCLcs>& zj<4}8vl(`I-EIeUO&FhRQRd#c5xthj3=!Wk+N?aL*$Zlyqwi6r`b1o}sQ8fJWGiMg z4?TBa?)EAkQTns!{Gh~h@M?Ko9xJbO8d%VlQHbAmk%605_*_r$>3mM%Pu&dW43>;? zYE0K7%|K7q`{^G~<>uXB)xRoP*pQs^nb~>McsiAg?9PU(NVjl6MF0J`f4@sRnr6r( z1&$c*7|~MJwKv9OqdNPBZawuj;0rD0d5KM8G$(1lkOYCOS}todorjCsvWYN)EC+Z& zTpiN6^0NR8p*Yv(T6ih;PQ>G14MA6}R+O@7UwGqePDv}MxE5;RbITFxM++hw{q zX$eaAyTV@`i9mmK`it+mb*+#sR4gpI>p4HaY~VOAY&|FFO++Xk-);!u=dIqpQt}0D zM;AWZ#b>VY{`eOgP^Xga2egA4zdI4Q$SsagAOLc%S$zUnj!~UQrd{8+E?q`yK?D$1 zQId4NpXvOm`t`GZ_-`Awb&#}JAIukC(^Ig)_v|^5L0seaf4|_bb-l}#h}{tV<(DsS z&TN^T6M&21<7K+vWr`Z%Ub6xopu_EOX>J6x&s#`tBozV(@-9-jPoWRv@T0 z{Z)B9PTj@qeztMc<5D%V;}?sOXtjWsn@AJ0mIvnZWZL=Z=U1^?nlU`aLC8!|kPgGc z8o1L_i=xrc;u^%jUopUh31z~1ZI!sBArMaSy$|tB9ks{o>PwNV{I!ZyAcj`pnLv9l zfKAYy+Iz&?JwkaSihTRxlh`f5jri^YlfsQ#m=;M+Q2IukuxeT< zf32UA@m3hAYc2|(w_jSf7<2XI@9fhyOd(o{_tl~ygO0S)d#EN=uf7dTM}q0SNKCwb zX7Sc&qZnFwa~b*Gcv}N?#VW2c@879ciLAtKz#f-VV;OXld59h7FiaXRZtQV+hG}8m zUqCXBOTEGUvcpzN7q31YEN3Yuu?F?fJDKU}%w+pVofaPeov)C$(4e*);)r*D_7vnF z*_x7S7yBbf0YcEN-N^AJeQN7qMFN^5xetL3-Tmc@6zP^b;0;unhR!Y&*zTO?OOE7i zQtbFX(X+21Viy=L%PJxHMk8!cLLxk$4Q_Bnbeq^*947J4ZVNjh`vhI&9*@`x9uddF z!Z=t5@Us!Z71rxNfT+xLBz?qXZM1<$=mEQX-s`5YDT$;!a#H43aYs8R@Lg-p!vH5} zSN5XT{l(CRBJtE|We!aQ+SbH?6^?8#Ib3bttlKr=*L?j#pb}!82>yADi}G6eGiVYu z|IdeCK2R8%!Ol)yo1=iU*McoZhJDXN#2}WlRz8#&X8PD#yp0m~8yWC9+$wi;oWh;P zL0X~bu#BerJNu`{i4}IAw1zycB4-a$@EaI{Y1kGP)1IdUiq?YV>&gEhmjX$%<-MjY zQ&^nB$Y-j_VJC{|4whP0K?!X>z}H%N8d8& zmnjjA_yl4dX%}W2q6s^H!+d)k+dXL*_@H4^11#p?Ejy_Unic>Xub4Ku46q?h_QnO% zw#5*LMiN=&Zk8U5+WXF+LwN18Pl9ZA1O(cdLyY;h8Oh-m zyGl!yymqGOF%f!v`cl6kXl&07ucWU)0p%0-cC<}j3A{e-%-d#bxXESp5M=fG zV#oXJsvIwoF^2n$_7_9#W}iF6FC|5tGgxG!Di0guX4#hD4&Bby5TA!FqhJ5D-yWK` zKza7`ttyF%(Ygg0kb#Z%(30UQw(i@)oK*rxf)?UR1Rz^Dw=+QBqC5A|Cf2~&&gjpdpT>UK`h~n|rm9aS0;Lvd$!2uDf0Q76FZ4UEpA*~o+$<2j{~*{f`1{Wbj-L27oFnK`#23W^@>?35 zX^LjJ9nTPNab2f?{Gk|M@+TB>mM1BUSCbY`8xMR?o?+uN^-`2H_b>JCH@8#oZi-uO zXZHS3D!ymZ31JOAZ2-lfW@=7*%tzaqK>H)x$?zVP^W6=mOC{orLbV+|x?K8Q-Hg@~AJBL1hAxL%dE({cYK@pIYLt9$ zQ=<8tcppMH$n>SWI>9E#aY$=d#J%N5qJRF7KSv&ji-FRSM%J9wFRR`?jXNJ19GNk# z=P(UX&$>7riV$C%@|3*eO150$An8X-3AG2Y)!)grM-Vr*G)u7$dY?=)=v4|$Q8zZ-JQ|u(O-U2h09whV_1R^NAGqS4Q=eD1c_=2L5n)jT|D$YErRiU zHyd&tnC1RuUP%2u>{#xJ`BT8e|7b~@&myN9u8W*AxEsK`R~gxw>hyRb<;cff`+)1< zg5CA$$!Cp0eHZwGU}o}Zg@}Q@?iddC?_5tnNTfuJlP4ZoUY%dDljNd;GDQibI%oV{ zc?(Le%?r5yTNC}`;y^CC+SJjVtX^!*Vhd>G3jY)Ow^sMlF3*g)ht$G34ZCEGpJhKU zu+}o;dA0cNa^=korOk)6jlO0r?;?mmQmFqEhkh3@tUeNba^WNyYch#0-50LS>(SJN znVL@Qj7vm_>4_C0Vkj?5KBK=vO9NS9o`Fym$P*Uaa*)JqK-1N0MRtUK^GLUNL`*Q3 zrVVy4oVZ9U)15YKjfsJL1 zL0o&-oJHXa#*vM!BUFH+G~7B_L+6RP78QmtDaJ0A&KxF{5y=NVkgVg`rdXviv6`RB z%Wba$9L#Ec-_?tM@}A6B_FK*T)rmT)sXSauZjIYmnYd+}ZUe09qd+6LEGKU)|NEz? zBP=#s{MC67Fn{(WQL)K>x#cLM+>lb>9KiqkaoyW!WDS`0yqA-Y5y>j(cve`G=UC;{29tbRQpFu=&zuOkqht{#Kfws z+yqfptae`*rtKKKypb%Z&p)c!b%cIg7T_Ycp;&twP<|Rvmt+MW!%@w?C$EI*E4ch{ zzBcmc(0S)#TQNjx|JW(dCC+;G(fr^RxphBK01iDOJPw*51$I718cU}FS7k(`_SU+% z1~2~cjYK{Ct&s87q;lMjmY|UG@G{-3V?=752nm=_%MV@TaAqeqTp1G9TcxS`uYM7srMXZBu8mpAvV(&r{ z`+U?ExDBVs`rGl);_qpFitMizJKx{P3%G4pKO$4!VQNaMtLN)aEhH>)3q%< zR{ddPsvfS~ED6((R00oF3hN3Plc3dx6T&xQ2*!Cqr>)j8w_}fM3l?h(;X3j6reqwx z6Bk|CXg|v3tJD$x9mmG)vw%_;EhBwhi@BcyytNb-o^^Q{UeKG-1LLEiWK+Y8CuGLZfgkUi>~bL=Kg(3_#K_In zQ>^0u;Baq*Slt3cZ6=r!%z4e%{d-vM^RNxOVz4?ThiYTRK{tHnLjuY5V1BxZcY(lM zUWqaZ^DE0V?J`^KCj~4;zgP&4%JoURY%d@k@ingFND!^WB#4rNfNgTrvyzN>|aOjgze%_pqpTlvB{Z?e1 zwl1PftelX%jUTtmGY5c=$54hAB{PH4;W0pwrJo(yXc|mCw&EI=k+of*ViWjr@qA2A zRMZayIwt^qe)HuK)2Ts_4p|iAm#^v7=rJ_Z>X1@Ve!^BQaS*m^$a;<7B>pwBR4Ssh zcg|K>D1ZkD7rul{l&;ng=tM2t-$p!YV}AJym<#-9LX_^y#6`krf<) z=D>i=Bm2>R9}cctoJp~ z;35Y4G9sMJjt}&&1CE?~bVHRdZ_^XZn}D&_F%8cNC4)AbbZa3rxquKGhHV}DQrF8!I@vbpR5-n4VlQeior(0@ARFq7DNe^`~U z`eHrN;zv@&%75hQ!bRX3;?oG?O z53Q{@QjS`7K0Cn!{ZJk0rDFk6Td*k4zMK`i1$Ho8g{A26Ln*WYH zY0Wb0*%^!3G4t5^$Ji9uLLu|adUxg2t_D6dqrkmMxPw8vlBJZ;v5w&y={m zhLS4w>H{4wClO%g?%57p@!DGNob~xmZf>s19#34|M8WZO&LvX~mL{uavl*3`+l^HN zEEH0p#3yEz)?y&1>}v{zB8Cq!JDY=VZlcb62G*$(SxiVH-fs^!Tc=c;tVtW)bA@ns z#c2;54fC2A^)n8jNPz1!Htt=emqOW276sDewvIu;pZTWAs~;0JXR_caGTR4nnE}ha zA9J>3b0)k4XGpO-Sv#|{d$h1xDjT;rmHx0gOfEb1f!#g6 zdrDt}MvlKa2Uvc0EGEQyd*XNOM2(AYJxG7_(4wpM&&OrL2p!m29$UD-F(@klYufmu z{|nlm33!de28vZ z;Uuz!T~m?LSxK~Kq5JpcpIO*7+6SMO$PZ~i(>L><)jyC+g!|k=!Mg&pnX>=n58PUo z@b+x^NtpHi1D*^_zO~%=sF6m8j$Ur_Y5z_m23j}Si!lqzNjhjmxnx?ne3EbmvQbf= zUrx>I^A?I#{F74*h`qx9BlRIexA~9A!~~f+6t@X->IrQ=o!vuz9*OuH)Y7)JUq7lB z54YhJD*q9K9Bsj??Dd?#Zt+aF;~A(=TpJmQV=i8<0P?3k)}y%NeIDnCD)t-`y{OB+ z0>pu)fJzvKK+Dt6)o&2@(tVne5(ki3K=mwraY&MTohn?Uh3@B4$hip>w|&ko1qj1= zd55V3_xl|2i$r=i9t73;jI!>W{<0l6s;CL$<{Mo2 zf{jxy#AK|ddKlt9;qQfB7Lp9*jKx`<3u6^=D{1m-v4qC>Y0G~j|2()Pt;@8eMTgWgU?Ne+W@gU$2$v~~_Y6eJ3L=-ncdPg53quX+zl8_5m2(|mArJbax zJ|oL8#E$2h5MFv+J~a7r{$Em2B=JYwaR49H9&=omPyUb!f6 z{~ZPA^}?=QYB0F$SKOj%C?j=A*mm}!k=0ED_m=ur-an&RN7XiCcKc$s# zGAd{+$^6CFj4M*cXT9zzk@Dsp=f9MJi)9$zo;0{9Z)gyWP7pkwC zM-kZ&aeMhKt+fj~KrHf6#EBCtwna^Cj3w~8PUWfaLj=(+#!rj-UsruA+#2J82+d{2 zrf0|zD2kb^Az>C|A5}_!xvOmxw0-D>)ujywTzY5dox!||K&PHYOa6B6#|@B|*rs~O{V+=^(eh_HyRdk~r7irp!zw#$ z7+f!m)3+-BrQ??bjh5IK?vL0qDSsd=@6bHHJ*}#$_evDVM1g$AMOD^yCKHgjaw&9x z7OIc}>k0Y?_oDN+#C+#r!A4YDw0C*cT#;?u08$}8qoi>sqAK04>&UBBN98*>|8@qF zK?y>1jk81ipLcEi;v$*B@|7h92;g(=`L#U0?*CXdH6{4c71?hJv8`2iphpkIgQUQw z^o0M#-kZNe-NldpcT$wH7_)!Rbl>;;dVjvx=MVV)@V&0NGR@5EHD}Iwp67X<=khq$Jnv7Z zlFFt(t^O@;RV_8iMbciXvuCH%=y{omvb zvR!hkcK*KiEIQV9mQAN1>#e>#Ixuj=e8j#zRoo-R*=+V)p4;Yw_8H8Q5xYzNrmx+VvJY=KUCLX$xkDB+Q*tvN;_-U%H5gLh)d{pO;5?i ziB0Rs$%zPm#|nnX!}0>hQ<;0iVQw^~l}PoJt(=S5-ccO?593D#v+OSp!KGH}Xiq zMI`708={TNuvrc+Q0gqrk!XF4Dhg<1eIb<0Vxzu4?x=i(vm2C8Ut-S<26-NsbZV1x z&5<1xCtylxmgC+1to)^hV92#9kCEL$6E&voJ|fNwf~@PZlRQ$ba7;S4xSq3% z_a0_o<@X>b=aoEeGHU_2V2*Hq)2>3H0agCa{v=eJjKtgq1@o6dm*@pInb}6Eva4YM z#!J3!DK(B;K)Accnhn@^g)oZ=bpWWsZ6g;@#l$*8g?9#bnpag`beK zL5m`X(_K;Jdf@hhOcw)y4Q@MPT94>DKIrr zMu>MXI0utxFD}+)v4#j&nt^=AnvDMFx7m4amw-{44r@Os(|!uEPYvHzhxfl!VZlI* z`{XA{)Rv=ZZegj-)LLKTNgBuFn80Xptpuy#6y^UKTR|ew=SlB5ix(sXU$`Rnfjd04 z1!p)(79Fmxn3X4AB(?45OF~+X0V5oq?;~~pMAtwbgATj1!Wt~}(~ay(JrYI(&ezLm z^j9)d8EHaxZ%!mMa$-xcp%Z+uxac3XR4i_Af;_4aUw>7;OmFBfzu{GK$L>0G&?P7> zVMgHN%6FN(gV)<5lc{TkU0yjD+(-IB4a8PlHs;(YTv0O|>lxlbg!>lXK0cU_#VvZa zv7jNgHI$W>hNH>qv6;ZmSQGi`<3VVv+S~cDSr)xFXnUQuymA5V>ITwQ$Z?fj*RtMw z{<2Nlf>@3nI%!luoSk3lHK2vHU?DPoh5|v_hh%h10XDaKci6U>D=ZC zGXXV8FXMf5yvpg{p+hGfBFW)RJz?GdgH+w1HS_kG|YnG^fyO?V_f>3-wx<_!gkngg-lE1a`m}a%Wf?;$P`9{h`ZV6umFv}D+K9}c1ikfeB`sKKxC#%a~BV)a6jk)UE``?_Vqi?@o zu%SrDatj{Xe|otvXkkp=ef7jmWd`vK6LVw!@xtKEtAU%PBqkORqvbmr%ec%c#CM1t zl9PV_dYsJgYOdAAtRa8m_*6aZ`cPmstP3t-pPGuGmzqBDjMrowR`2R>v5+)o8oc9I zO&j%PoJhp~y``48V{o9U`S$CCyJ%7&eEAV7maB9P&O&7}=X2R3=J2F;2VgMu3Mx^r z@x!YZ*g-tUQFDf6lM4kd7+xiHOAf!Q{EdG&KF=*iDOVF#;Z=91Pp-rHsBdiF788OpT;2#QSQ~oympLDFN!MjTARYzEb7#>MyG+GvCY6CBxb3CkKaRA% zDGrRF_KSUf0QkCH8qlg~t(&n>?vGGcITpxL}{Inq23vBYW_7H)O+&?Z@*a zeMsN?w^HgV6ZHmInn~un98zjt*FAP=x}Gd*;?Py#%W9a>9cMZQc^xF>w;>9-q7FZe zXW0v_$kLkHD@HH*mITSnIvLoN@ZNad{Dm)g&nshNAudLkx}N8Ut0CKscmudz1+n=C zLU9R^{R7W`k6DD#oAH=lsVc+#zyU^h(X`9dL;7{%%%`Xk( zL~{%rGvL9j13jyOmi=gcUu7G;mSdbBCRtduRgHVib8=(^>pP^TmtlU&y3spx#GR2p zzBy%$FHF?aG99lsgO7E6i3~_}DhieR-*9DW_&Iy{^Mw8%BlKn$}fh zVuy~cKrLx7+dO^X;eLp^FnV$EMYQsBDVYN2Y>f2Gwy3EMlq_@Gq)A}$yBt)0{yw_H zMSvg5C~%7KAKG_YLPFB*SZ0S2ss8jyd1)^rKRGj}uT3uRVmBk-2rK(|qtT6bCQkW1 z+!-tDAK2XrNsd@Q=yMXkIXhrz-sT7L+~;-skJi3qJ=Y1Z@39NVtB41P@9b)?%IwTwn?VaV2`qT^lGdn57F>2{Gc%qWS24V_s$RFqbGJo zX8ZSdIvhSU3i8rilGHCC+@pt*)H9r=SGScoZ#^eHC^5%a?Xx|o1S%JFZM`KfY{Tto z+4lLOmEW|*9l!LPAjY)1t^6)654%usU%Muor=+U`B_e4$p(}Q==fk}+?MM5$gBYG8 zt23COKXdYozMM<%JK~p#YcD8H&Rul-=D*gG5{ltEZS^Wc=TEaFL$x&&Auek}Q+L#m zj+&tP-KP*Tqnk9dy2T`$UEXRdcAk>Qg=}ix`C0xA2h_KIEVe+HfUi&bYV%|$=~~N| z%`;Xji&Ey&(DUubA>QTJlGX6nY&ER?Y!7BxtAfv6>jAI4(F89fssDMj%jZ=@>pI`` zejlx?2tw?()>7=omz|l9L$MhCT>H?fY z=iXjuOaskm-#4+=WRxc7-~<}bvaJOGzJ_BDC#%=Me2Z|$ol%Vb(b8n~j2UTMF<=~! z$xuv4<{`-RlOaE?uQD_MxFMWA>HSxuZ!7h)1k^A2zlPX)|1AUvgfQz!t^u`kd4^gU z8+M}CCcOX-rA^lvvG*}R)e?8~B~_>TBA ze$O+Z%wl@9ZId{j`HxN;4!_|Yu}!+s*}$A|F{fvAr2zCxoB#LlIBn3$|Mcwt^KJO8}+wW(Uokdrh4O5l0l16ta5w?Y3Kso~G#!KCpndxP6c+rBT}}Aq5o)y>o{DDHd3}|0)XC2Y^~p?W z|BJvVMl#63?793Z+yA~sUH^$cemJk_!}pV9Fc3GAlbk;S2##|OEhRq%u;1{uEnBWu zbQo$E7L}i@naj%@`7@%}wMNZWpTc4X0L{T0YW?Xz9e`zUV`UOqnc=o-&T1jeCKKld z0HSAgLEZMq#45fj)u>_aG-c2>=~ST3M@H>nLkWm!@h ztDbyB2IG3YX7Z8JKaGv#w&_%ys$mSc{Bx%aTs>JAsb!CvzW^js=UM$~WvOmqNv#j( zZj!pPXeTs$QsRF^kiedwe)6GN4ySypQ3mz&2Li@%7Ivnj`PvTx_lz+)<)5GbXH1SS zzlEX-6pY_S`v=hgtAn6m0iJr7_Z(MBeGKDH{+YWU!$_Qo^2PGDJfLNO{<-9IV|LCS z6DkzEq(LH|L9XdS1^j7C>AkPot+8B*}I}ZNpw7dad^+-6mGcv%_x&O~!rgKMT6pCJs6^`i6+uu}C zEZ)2$(dU0p_RN6V8&HqBnHRnzZ?4Ig0n8^;4gasi{^-T>?|?V|M_#F*&qHNKfexL} z*aJGfC5{Z?PeMY4Jrz?wxfLnYf?Fn2OT)c{%5US;*9Q+lHPGsiXB&t*44EW1$f4*R zM(Hr#oc(&(&a@1|+IN7EWAmqjh2_t1bTb4Ko(9iDgn~!<1Uh{#Bj?{ouhDpQ0N-QR zbge3@`@r;Vt-qz)Ty+ZDG57s7pHn}7sdOI@`uTcd1s}mq%GzHX0D+?B z?Aj#Uen*CkJobZtpU`XMdiev%x3mFY1SW!nePfdh>zj>vx#^S)IKL+`v@iD&np?u# z-wUZ;*}Ize`TFJK(!^Y}~Z&|0quApfZ>fs}+ zxX+ulQ^=sSHV1iN)CLW{4tAHr0|Rg#=;#`nP=%(x?Pz`ga^h>}`r zqo!Oq0~VA26u0-^R%fjI0A}xd5BOGgU{~qo;xaH8}Xn(Ng>q5hZ+eY57wmUWkjoipj1#5aK7m#y+>?nS0(Zr6WldKh7 z`~K+oKSSTXm%yWd0;i5KK=J`*g+$plAtRB08&rRM0Mh!=?x}FzwgTkjz@OF)12AbD zd!ZD*R8Q~x&$0nPDc4~D5&UB=gfj}^x>(NG>T$Ex|C3Gjb;a_!u+KRxi@9Ql8LFwlr5oD}&uQNdHFr&)B?%zX_1f z40`6+8SkJZvPs1`v~a?t!IN}0`t5!}5n7QM{$sbAm1{1c${TNk%>#mGpQH*jCa}Lv ztnx5IFH1YejoV|VE(F`O1p>+ZV{rqW=(wVN*Zl0y!zt<#VruwV zn1IFe@$&>y=iK>u8*UuT0WWawJ5;#K%RIDu4czFWIqg|u3P5AlpMJ6GqN_8{ap{|d zZx>D;GE6{+wE_5azlNF0DW++;YnQNcl}15g>Q29q6OTmwSP$8213}%%JVa^pHkD`a zp!0Oi*c_qtP)|iswZXJwj=s4OLE(;(005e#p&KOJw=9C<+ltIN6_2)M&-s6}bF53F z#=B%50?1%_8BomsZcIDZ@A2S(Rr-5rC<=hJgv#es#8+((PFus}tF@CMFW=%LmIa{2F;)+CM-q`0U7!Z=b=hddUw z#Gw>BCcc5AZwE^?CyYrf)qo?MpY0xe$T1nEJ{}|KxVy&;iFRer0^;-y0aFo?b?yv+ zX@$%I2zW5-lN9>w{)UzBA*9oq1h9lLn3$;;O^__&f7{V5gj_B}wsV$}?Do%n4aI-I zB)|ZXzG!1wRt;xXO(&S(Z6!8%{o=woBZDJvZfv-fM&pp7Hk`*|(w}Qm9WED?N=V4c zBBxIJBP@hCE)Lfi!1O?03aM9aLOiOLSZ0j98A&JtEQ02Xa~RMnW(djk=BEwhZcC}` zL3G!Vg`)ZBxrb;5=)v1UH8=X^;b)*C2C*36jPXfMb3y}BX_q4mKuWV#H6@fIc6v(V z1}_d_1UwEK14zYHwil)VN>-Z)@?b_v^V`9;b&x~?uHg(K?r{|(n=1jYO*Wze!_=iAj%bF&iD&Utc0$8~h*taTO*S2Qk zK_mto0)ee+Atz$Ep2%RjZXaL4Py%*xZF_jTUPq#a$L`ZJ_Y#iY4Sqg;*zJg5y;5FK zXWd3PppUvQ2!Z|mJuOq-w_=ArjOUv(3Xi~0>+s8p>|hgm9IZ{eWN&h+)>tn? zq1o-Wt#O%2Gjd~lT_-k}ug{^|r%41<<6!)S@#Xw$zxUZD{aoh-a;aypU{J%%tiaYK zMD``LK?`g5(yzsrxzO5o5O`K0d9f2cj#$?zFkuES)p#W?t-;}DI|j3k_jLu2{Jlh7 z-6!Z&eu_cF=aA_3Gu*0iRIu^;%q~bcpGAJ!lPJJ&K1*;u3^xML7A!OHwlP1F(_DiR z^u}r;^Y^se@dD8pp=Tj6bJHEli#Wn=a6%3puo|I60O z;v#n~naSg8yRtjA)5<-Wnf?&h)|Sk4S~@K84K{e~8?_2^N-Hy%IO;z9j3_|%Wvn$Y zQ_E+4T+fUd)0n!BaaR^*Gvm1pT@uHasE(rvTol*FW{LXSD4uI(=j5b@4+c^|-T=L4 zQA6*1#vQvFwY8PR?I5+=Rc-47`=Rq-bC)Qbu6#0_|F-^@HFO_XCI-VvZ3-N3!0Ria z6Q&pC*H%qwotw|bN_;xV1Xzl?N-$AhAD9J%K;nePX>I;8M1U(reu;~=j`{J?4$l7m zkkG35tQjs~z@aJq}n}THk38c0GS4#+ScK7FtP|@T_6d;8Y zRyo3J`ek}0KKU~3ML+CaBMEpUJ~tBABHpz3b>BOPNO9~70v%c2qCv5j zf1Ze1^hD)=LjHD8!pMeAy+Z(Z7+K3c4 zKw80yd^rcWj8=K-uM!Q%M~v)_02S}O|2kLYsJ%uPxR-+4J@=5#z?|ML#Sc=eQK{9& zc=5ih3b)jOdbb13K7DJyY+#pnaes(04RaE4Lx|UbhtcbJk)Aos`dP*xXAuE2fjXTO zU8wK6*QC#8tMc@*toHhq!xKmcIErD80)z%i@9+t(R^zi9wMWl`>--N{`68QyP&}FN6dW~!2cGJQcT{Yf%-UT_16}&Q8JZs2eK|?1&MTTwp zFA&Z1CkA$DwJp}u>-hpzS3!d_qJkF^IjdTRZmS{~Aj4VV^lj^JjU6YYTOnA4i8i9O5YNfUIj-}rD=GOE-1>jVPpx@#PU<&M@u1ccl31>}iM6C_sPY#I?Z zPvCMqDh_{gTi;1!6%b!^v!I+_EXXbz_WJ2GOKl1g!0}3@P|A02Sr8DQ+-`Lv_hg#a zWTE;|&_OpeJ(X^8Ate2eL?dJ3muu zj2n$_Apz8UB7z6x5yu`a0gD;&BjLq3j(%t(3uV*1?OPuFt zwTKQow{NL~HCK^XVcqJKsaWVzyy(>ZkISmhx8LE_^6xEKbp1tCOqmH|P3tcjZFj(| zZ^m~)n8%0q{1*D;+v^;(&nr5>e72w4^48*5qs3rwy!)ppNI!zpCFc3BIi35)Qz z_5-m@me`{=vxSb@v54&#VGFBmpHvTD(a)F--rq$^YqN@>`O=X^oKnRgUTr`#sq*M` z&`1aLT}}|seX7TuX_T>Q7R_eQH(x7(xv^R`r&Eupb^lRRl&+b{oc>AoEw$+Q`nwI< zCDpG@CpXzwW1~;Aw!ViLI6M*hh73tQ9(M&Z{E@JoPytYHf(8Y`>kiT~t@P`@T}z3@ zGU3ay@Wu7rvseroYa?@N&-)_90sbhPQtJ<)QbEMvl~jODSRyI$k|3A6VL%yr*enjT zO8ypF%?mXlwD(sv={FPohx<=JE)wglzzJxr_>Nrlj1CYNnt7WB$dMG@$?B2!gxFtS zpB(F+m^W15`LAHz-a7%^&kK!(%L>d|uHD6qhweWvqT_d27`2K>aq)>z@33!Y`l^&a|S96%E1pIb_ zprJo=Ui2hp^c8W!Ai_XGh=!2SThxvWprh#Q5Sl( zf`j(y4%rRX4hm_;&XFl#VFfh`Lq{}mmseuD5&J9J4>qj9Xz9rl^nUZ#*Ov7`9q&CJ zB713!(x&zOL*?>}eN;7hsjjtRb-YT-!-Y()Q-EK|!TgBuDxl!CVRc;E>MV15V5t^5 zKa%oet+lD&ze*RvYq6hsYa-CEqsuBfk-xG8wI+h7TDl-yWR_U3L`?@vE3KPSMSZxT z6E_x6m-=|I%#+1Xr#uz<0DM4nGw3uz-|Pl+JnY$r)I-JHjCRzjt{t`7(>U<{iC5&> z0MoElG-p+$0v$f*mlLo3Y_{ldsK{}^@KLSbvr$IR=mPZ8)`sgHYgKe4#wCc` zfeigLeh}0{SRY*)W?94b z;H@=D`3exVJuDe=S}Uy7yqq#ifbJ!C4_?y7`Cn>1qa7#Sx6z^zKl}denEsKY+g>?8 zqx3b|1}!sffD6uObo+`LerbdrGvx5j%Fj1$f<%AM3G**$H2h0)#Kvs?T!WK_iboi5 z?wC`N5h}?bmHpNgf81C32J!aMc2VRJ+Q?0Zs-lvWB7|9?a39Zm?-_A5p+9|#v@meO z2^2S!*d9R-0`77wl?y1nKaPGG8-2M^*5lpBRc~lWiya$L_PV8v;&G);4p$>)Q5MQD ztcD@)2mZKmpVqN0_wO-^!I;32KuZl_Qq8x7-V&s#6jQ07#xx^XCyO-icMHAMtg}?- zaEUQ3o8<6jbzxNi4l#Wth<|%-fsO(3zv52w$V=i`gP9YNK@5i+JTe?aTm~hMZds$X zP}e7*v?tcgmr4muk|vAY#5v{XPR_%hQI*vgkP*re&EWx?=mfF#^cCX7Qt#^iw0k@s zqmUk%&Ly>WPW*Yg^}^e{6>H`r!=b(vgW5KvDT4EZ+?8I0vYDN`=zm zXz?FSm65{e6zZly$tzPaWyn2#<-p~_F4$~g)!@{}39xbJAm~{Fs(?o2 zHOGa|TOjfmy8Xi#>$X@NdwJ9qk+l!vmD{l8*wZ{Y+zm)8NJVb1DD281@d|^NTzRPR z1vev7tJJhA;ORiaUqN9Mmk{m5Z!V*%{mfF>dzp8=$FE?Q{mOUvN2wMQoK??f4s>!U zRp)F{FQNDr+IqjBsfDy&!2n~h61MV6iovUA=LZb%(mA^~<4+zRgS&2I^X)P0UOHdC zos*AY=X3mLO}Drlo`Ymr@Zl;1=&l;#(ws*c&x5m)VE07?#G}h-y6TptKx5@(i!{4z(awblt)J z<&HD*4ZffR|7$dt zPXyMtq7yaCau`&Q+wRtpi3Wt|Yot66v1+14h=xBFwK~UvG3+=7`$bEjH@ixD1oN1A z+&S0PD;9krqeffbTY|7VKs#pk$N7lAC~n$_isT=<>T&}PRw*Qh$gpw9xki=E#dY~D zi$OJ|yZfg1gpHei&e|{%XjXcas+b89oRL?mLRW;5hEb~O{hFUic5FTa#-E0<^{~~X zE4J_*s1!aQ#5b;ys&+*AaNITYK-Oo3>?rKFS~4xRm^|#!wB7Rq-_^WdO)X@{^y7M8 zf6g@zu(&nxRX#|?F>Ki@gbnfrZ%I&C49eE-axyzM!BY%so$5$n< z2KZh{#BVW7Hhv!ZmV$5gOyd`g8>La{n*bm&Z*^FM|ub3S&b5#^U?C zo~QqU4OcmY51=(#sWWb*(i~c+XJR&U9GJDeVy}bEJ+FoDj9sW(tviWuabeArE!9T; zgcQ5w#L$U_M~JELERb8@`J6d^03Fs*=@lO(xevQGw~0;&vp2%;uOz2=32ZxMguc%L zJvo`t7Du;XiSGG+Fjn6V% zn?rs6t&3q425`s9A;{+G&q_!g?WGzU*uc`QnG9ZFznbB(yR(AEU*S7kvO;|@{4bg} z`7audJ2vVMkcPx5i1ol4cYh2IZ!it0xYWAG=*s1Q+UXfuUoqB^@OxT!=51F4?@@UV zljxLFH{I4M5p=oG@7|aOnLWCc`GvPMPj54kM8QK=gP{AnMwYC-vZDsI5pZ5z$eis# zWsoqNc`vGZiw@ya7l3)KZC*UfED3J#B~VrKv;x$F%=ems3?Ln}%qXD?N?v2w8LwS> zyT~p&pMZjE0WVba%`H}bCg!gy-ks4V&%n}d_rBdNxa_`F)uS%#PS}IdQfenX<@}lJ zB9RKpCIIi@(^zAixNPOeF)dAj1Pva(UfjdsB%V)j4z{{(6zg~{=l!0Ko=ETrEPCk zS7{-+$?EI;nABZkM3q#oxN726yD_X$dxMOtrQ%WtLQQm5AtewUv40)pwaDxBtlgxP z*v|VsE?9V3Cu`wS=SK?)H)k9d8L*Qna(xK6p*b|wL()YTU_U)gA^BC)o2>9xZ#Fnmbv~1O3F-S&>XnVsQv>7T($a9#^)j)x+7U6+9ve1CNHUK-Ur(mq zW1Qz7p5Qg(RU(a-?D=+x#Z4m3blHzB$Oz{t6IS7_<5$zK9_NR6drhKuk~Ut9zJ2&x z%n6@p=ad4vFSA$$x?SCa&ZS*xcw+P|HHN#@VoO9i01MatV2YTZ6Es^My~r0&+6UWM zeD!RfJ@4WeG5q?1*}7EzM|tZKsDJ@&ZLx9rjt@^MoZf)jPz8$C)sA4ahju@?loN?+ zwU^GB?EJJ2^_Z3%+#2`A^6CB@5^D_0|H5oLxz4S#d z@x%h!f6OSeRSiu3GLI0jp1MOKr3ud|X!tCD|W$x*-k#J^mGMdgZEjX16}t8Tk% z9LNy+i z@%zg&srD_OmA!hs{2eSXab9U%`60EfcTYoPKV9`O4k`7kw0w=1$9Ei%afyRJ&{;~n z`fzCAu)#UCA+r;W^1R(Lacnt{8fo8mnD4oKEh&bs)pk%Py>^^+M#Jg3x_&mO$L&wp z6BIbK`zm!{ZbXiw;*gY*C%E?5r)?2}idiij_w8b=wu-)A(sx_=9d<0sy>ad2&d&q1 zu%M^QM1>K9PI3f@Mqm3LH{;Fe6G|dN|}&&iYUdatb@vy!@`#ZM51>0}-1u z^8&*&zu@u7t&M9ylv-Y`d4LLaCo_@)1u8vbTMVB^r2xCEj~q$lUx2wzk%bEdR;((^9d9iUxjL+l}1mv0!NJFSHu| zuz=mq3d6Ys^NTfaqFp|Z_W+PBA^1)98fPnrk-%qd!0~9py-m+Su#O}`>xvX?hecNM zOsI>F4x<%HgVF8LIZaXE+&U0b6Oc~&7DrQT(p^WenCW?LgHxf2U_w^fSipiZ6XKw` zU3VU{eyToBd3-0Z=7Pd!Zbw!MCa!cSYhdi=K@a>iV4cE93blwdg&bs6z!)=}!wU#k z?cF-QbG0g$RYfx1J+*SVaW$&*c!KWnZ0ugtU=N)Ni=c}+I2m-pTA!34attPmKO?pQ z|GR7O=vaBH1ICrGHm%zlkiU2#ryM7kYBx2HhrADl~d%JQ$vwxL+3-z6MA?v#wOg;A^kZ>t>9RmX3HUWut&z7AXp1R zYuvUVpGKcHO@)^-q`vN(o#A;bc!*Ja1plVJ7v1!h=*FT7QsRMgS&Fou{401u(}U9= z^wy96@_f)eKW4}ktsnN&V`z})+PrDv1>5Yus4= zE;4vYd!u(}$mbX0-(}PuSjsl;DULq&QcFeh>H%rkz+Oz}xnsl~pAQZu7Vn$liz=x= z`mmu4b(XZp@lWPcP4_Qg#WfO2WBe!Qk2D(l?zw-z=hy>9k4=T4GBMU?t;;Mw7Rk$Q zLW_sv{UsU|a!TEd6ZHBwdZge&hf22Pp?3T=#opOl0$~+S9N8Zk8hNZ0YIPzqc*I52 zUDH@;okjYh35$ zlzh^*2b*QK^_)>2S~hwH|5*{f=i<*I#E)&E{UVoHrG)-R+Uq>V;Stei8j!$){7ig% zBMDSzzb$FgX6AEqbDSxK%D-KSOOESNJKQh5*%zv?L)$LbJ<|1FSQwpYVR#yRke;*t-T2;+jg_8r2dEKF zDg8Vq5A{I2q5=KWK6+=-`Llz_>C#(=<`cP$aK@^`yp7qeqv+6t0siJ^Ssd{dR-}W9 zT9Kn#k9mK_H`tVzr^^TGJ;Gi%rSER8;E?$?naw@}Rwhs6CPdWqkI7JyU-q_c5iM8! zoa!JkU0O5qf)L(^^n6GdtI)D%Zt~E-`$Q#aE&HZjhU~rTNOMM7UYVN5ICK!ueR~}T zJ~PF)OD#O!2pGEYA4}y!j&ovK4&dC(2;iWBU6cy~?#LhkFMxd_#@rcgO1_2%PMb`A z_Afh9mqW#fqRC+^jTuG8$+K*=)zd}zJj}b%eB4n1g!h4;F}{OWfpdnxRF0ywJ@Ozq zpF8viqZ_HGeiPn=p)0z`mtQigSU%*oQT4=X`IePZ_SYi^CU+r52A%_DaBFE$ZEr}P zAc6L5aOPo?a#?cp#?jQvWZW&}&`Dy;5ZEZ>@hi?f`ZUoy;lXf9@bUEcA!r>OfMCAZSol0q)Vt~D=!y_%X80n|ODO{_ z+^ozCGV@^Mr+BlE+C%?W4uCl*P>QiuY zv-ycejf5|WL!G6KAg zuGnsvk9(Vix~iqH%cXS#NtgVZntprsx->IF8pm5B9`U7q?%~woQg#dUto^n|ezlS> zmJJ0!n5$B=agO1F<7IQ zhCb@~zD;xB%5D!o{7w__W4ENsYg;+T{NB~uFTtUY@bU{Wv5ND<@$Rcaa!Go*c0Mh z=LRj6mG@r{e_4WB1<1)bkBsDb|bUZ1i8) zCi3)h<4i`M88e^1tun;i1%9mIo%QB!Em=cRk?XAma&AZeviyDj+wonu_YS`PdjIJc zRI_1Sgi-DS%@T#OFiD13NN?Z!A@}IVJ)*f5sCq=9^}){kEb%=(C8Ba_dD4r{1uYfU zd>P#v)cNc2p|2+@k9)jyEi_67&GV2c-DmX9j^Ts(&e){tt>=7^fGaM&6EZV0a&yHi zg0!i;MRxWQn&Kng?V#8UmMQ5rMyf3e#Za^Ii}Uoq0u$n5D{pQdc^l@tVL#fjG-VH_ zfA*J{=+FXN=@Ck_Vhnp}VTxaLWtzV-D#W<1IW<6O_SkLj((m#ZJ4a5gNU65^mqIK~ z&(X|9GDXuVK4?HQ8q3t%*G))C))S}sb~7v={2cDjzAv7byLZXnq^#V+Z_f?8&Y?3E zkDk7E)6+Yf!+w0Z{uT8}=%JkGj(rAyK(XPLjSC^E61W56=!H~&k&u&%BNOoGp_Rg> zZAACSjfe9n*E4(gn6ln;!?5w^q2Idje}z zsL|>KSRgn39e*|d#H3(n{Tv62IB>PwDKLOhl(eTR}>J2e(5)eR1^{W~JeKJaz_v zo7gQwH08?Z(=ZQn>l0M-;(NnSu6m2ao@{J7Q`LYP>R?~GTOhX}9jwj#``4*>>pdQ^ zay~JV*`rr#bFdjk+dYd__MDydl2hxHrpV?wo5gjsb?TiN9PQfZcBIE)I7voyi;PlT z)Vu7&2dF}HB_I!I!(vw9#G~gtuOOnHL#<#lmuCPF&&z(fP8pqJR+zj*Pf$0> zm1NuU6kJZ$@=w>A>WQW9g1U>!0@Brmp_9wUoDZUe%&h68Bg(IBf(S>3O2mJWD?z82 zb8{gK7d+}`v%n>&Dyid`JkEKpgEd&%2?NHH{|U(V?1kmJrSHfnVSc*l0RX0>^lD~b zB#gw{H9C!g)3}c*;85&0SjMQ|5kXMyEVd}R7*=93_#|x69LXq;QE`yQdk!T|a(vxk znY&-WFQ0HXJ=VBdgB8^K^_YYO zP?+gr_ktNO2#;o%YW+}>9;GQk#}_c33N`AsO^8sSXEZMd{)Dv?vws2SeRS_ey|ayY zn!1&rK<~B`W*16-DXs9hwI6&JD>It%IKFx^x+*WaII_awDJ&9dkmbY(dYaX+v*E6d zvJ3y#8<{7!A|H02Dznz?TIZ)NE!eTe!#W+nCewxvmYQR9MR$)K{M|PdofZR7h<7vP z*M&svbLs9YJo|}$^JL%|*^N%|O(!?2daQ5GQHiGqelxMrzPLlfBN>s^Ci1hB%Fo7D zgn+uKXldZ2o$^P9j0A|iH?`E+{cQ<7&j}=|dhFiy-Xvf8Q@=;%J;{itX`s~AXJd?H z%)Vm}9Q02#9#@$P1)3lz>_dgHO@PIGb3e;w@yBJ6^vZ7Q9KF{YW7NT&FGstH(nJmC zFXbHoTvPp1jq++AfJ^9m-@-STmyTfG-=&y>B->r0`Bp!ZB zA2%x?IxKbPESP?W)e;C@M9Mx*k?SMGzYf_%UJa=m`Emfrf(Qd~3pM)WYpbk;<$qvP z%SMgrUE&`Ld~r!{R9fa$`h^2t+0gIJ3jPM$;hp;xUVPgi~hx6^m`3T z$tc%b>?Uo>VVL==0KmgMJeJ_~G+I|AhGv7&{jEHxc``P20MOUH zdb^(@$5fNz6~tE({VbZB*YHg{d{?GJ#|7I-#eQElg8kD*ZFS=u-S?#jo;FNd%a5buBp-sSJzQq3B~!?Sus@9v?m{8cmai(dG{s3 z2zk4n;NF>K+|VG8lKYv-xmt)D-T1b`!XN73{6Gk|_F9z_0XEe1)4V(;{;~JPuj~tSGVc&Kt?e zKtWl516fkI?7cD$2QnyDzLMKjJYp$UF=df0ewsU^ouHKzyw_W^ASm&dAHL;dm7GvD zQTEl%WX)L6?91+fKv69Tx$zuQbH^!flb#nSvFIUytBnFz`&3rC)XqjzoR)K@G2IYM z-nwVWT+XmHNi9Sw z$}EosQq9Oe^b>wLdWLZjLGOADzjoYEoWBRseLeN{fyj6TZ#jpQ#Zf9RYhe@{suqq+BNra0-<~<~ z6uB!9eF*qx@pC7NM5;#jF=KuN)P6{z?7Gl;!z7Li(z$QpUOeq&_Fb^|PiFrRxpncQ zipKY7qh}jj!sqX-w-n7X&e(9<=dk}5EpVPqu^9~EyWq4s6BD@Q@+)2G>l$|y@h>D0 zZgr9NA0yg6QH(@daDP`c&1Nw&e?}glX+#A9BUZVF1|G;vDSPt4AA3nNe{SZn<75Je{_75il`aVkhGF#1Qq_) z5pjxou)d@;O2P(h8Wb9|LM7AJupk%$x_B#V0z9J+_???VqVsxuzTWU84{C zEqRAUVkkLoXuly1l0c7J4!W~d=3>LR<`tO3wy3|dj}-6^ewZ)bACoelDnGd<{@q6w zm#zF0CUZQ6_}5L{cQhS_&nA(NmEX3O!0lgG${n9_LOzx$KX7pO+URS_w^)mJN+WTz zvriNI`70Llp}G^7V8+Xj8#)G#-M=ts7O|(3>rkYz{q1<4_F-{{g4oJ62jx2`-V{K9 zMk+KqQVeS%*Y5J-`C3+Z@XH+{duF)Ury6HrX7E0#5H}_Sr|hdcwIepDZsZrl6)y16 zDR4X+OF8ZX;d1lb zfdf_ne2sz4KND9jcfW_Kq2E39{{$?bb==UmZ9^po@h);>@7rv6X^uG8J$>$|1TOuC z^$UkRff)8Pd9|im{x8T+c(j%=#P`#N515dJ5QVg8-A4B)ykC$m5Bka`w%phH+ES^T ztt>m7Y5Xl_zN%zA;GCEmQJA?w=O-*f*$T1~mmBA223%g2;w#Fvgx1PH3>kHz=z&31N2-?0!mA#p-1qFXNI_5CT?8w4?_8~fCv zrOu`7Q&D|-e21;driioKRK6ceReL0Fb!?NFh^_dZrxV(n7P6`BMJ+iU#>|Nt{p5@= zx?qL>Z4A|A&RHZd+1Sb(&{ywbD-rilmhR^9n))-HI5Bd!M3$x5tKzz$p3;1{J6igf zTiL|sS@rg|ci3fQ#gB&xYoVr}Z<&Q{e)I6I`{DFE$h*-UPO-AP2xn&}aYjejJ7?l2 zM~ohjE)d!=)(g2iWt1qu0pv|yKVA#0<%wUN*?c2kp%Za z^@U<%&u4IAg#jsW0tpDok9bucctTCls1W$?=#{pLR zSajMOJvGBB>)j{5OniAkvi6HJTI{M|$|sr?W#BNL>%SDNH`L;H1aF-~Jao#q5h64C zQn`cA&t#JX6k_|MfgKKl{{AA)j+QuDXoX-bGki%;DJ*?BAKL!_Msuk!SJEKuSwE*_ z*%2@@Sj60D(472v5zwgXI&x~iEj}?cczt-v>FjRx@~TIJ;etMRQbuQm!Hu~OCp(Dl z-I4~!YMxF$&fMyn&Y|?qO#Wd$ygKOl0NAaG9 zUlq%wPv1l)C@&l3L~Oln*s3QlbH@D9Bfl-De_HN+aP_R^(T_j3LH))5AF|#(p6T}w z_^$}%v?1m=XF55g9CBFBB%vcY%poBYlANs^Cx;P*lqHq=M9y*+W}~niV$Lz=^PFb0 z!~OQX@4tS(e>@y!yRPefU9anPe7;_Sj+1po&Q1S4PT~^p`v~QC*DH-kF`T_Z14(8W ztdo{VOvEVZG>$}hk`7f-mIaKQmUiNGhYV;So#)R-0;y>GO0i)$=(zh45|xO3@>d6$ zC@W}!;5zB_u@Y#+J#t-lE$aos0%~@=f5HEW-299`JJ-kf9S?S{_$UZ1NlBRR2oBtz z$SJQW8s?j`MbV1xy1RTAFgVe74KH?Xm!Gy`{1%Aoa7j602f|&7QyjEgmK$zRGThK| zIJZSd>}`iA3mC?=Vo~v2I8&bY(H~F6@S2#;3huH!H;UBHR9-$hcgi?wcdv^P#)fI} z01Lb8HKXH&qxIX&zZXR%43h$%(aq?V%<^e#Y90RQs2t-c^&0a}bKAKSZJq_)&0cBWyADmTO?uk8p>C9jQ^9I}JiCK4(Pl_Z+CnIc zXX)Cdw(Ry??042*=Flv40-7Su6k_D{vy>Q+wbR#N_#|?HK|IBLkQAdLQcev_gX~28 zr0n}}Vr^Kf+MzFDbL3NxX3w?I}cFv*?D zAz%vfP&)-ma_SBWyq`~d zb=>`!u*j?+buPwh^6G3fS9K?JSrrGN39h5no;MF}Z0vTT1wTzlhfB9WpzT|1Y5AsK zP=P2t3iGIkd;mYq0DFrpd%|052OlP@IjTg?h~9$sDYtRbE^Lxx_Nbv(P}tI{k#BW%$!PP2!SrL>!(X$U=avSASh zmc^Qb*rcqLZD{H#l~@~wc{gZJiAx9kc7TuClq>;3pN8Rl`=9?gzhiPVi#v!Xzg<5i z0dyUTvvYJH%bh8Zpr+9bsi6nbQ=K8BN(?!7KIVgKoLd@i$G5c@{-@0XWd4hPY>Gf! zi55|&a(&iL2upHp}vAxoS!m#GCmSwPSA?|0cWOQsln_iS2S(U>17 zCgc#*=P$H;FevBaD=nH-GpDSm->bm1aT_%oJhX(X|D{> zB3oZd*Gtl=io551{nG={m#rJNqmJxle1UyJjR>nczBx%$y9do1XN9onMI{-7N#UWFkvn?ri&A4!)Yo>}|YXvbi$rf|vK*jY^z&ee$HH&5)e=L`11`MmI}D zX{}O``TaL(BPg{bv)B?f;D-2jT%DG)X*!91xiNe-dhD~W*4TyI7HD>H82b8p@T@+C zSdvi`EE5OnG1igQY<#ko=3ML;azHiaFT&qO>WE@ArL_Mj_U1cN8Wu9^WRH34h045s zs{LVW_WmS(zapX{+Bjgh=e~sYWl?x|)6R{{%qO+gn#@FK&44e7Wp;rh%J$&H#Z7mP z16H?Odz3h?_IBo!;Kp7TowDy5dBXJRT$_Z@fl$OZhZT%__jMfI*%xzw-JweTP*osp ze(u_ig6K_snKj5qBDq&D-^|omwzOnQMzEqR4gO*)tt-+LSu}ayOTWB^9Upy7#+1L^ z*Rq=NS`D_}zPZ=k&>m%l+pK=+5n~lO#Mf`Hz%Am z5I!~Sx%Q<1_Cqp!-NVKvv^PI*KZfDDY2!zT<{YJWZ2R?o(5VS3^&gYblJI0PHx(kt zB=x7We+2z1dv!;Pf~;U$cTn?(n=z_lW64AJ{&@nokBYm84e!B~)vROo=t`w7q~sH} zRWDan0VAOzPg6vwO+y&G5_yi2_44rsQc2&aq;I>!x4Ztk=aAg~6X$`|zR|)dvwRG_ z>FE~Z@`I?+TweV@h?&e_yAkmpLoNjW{eY+~>U+(1nDZAS=#&R{OK+2&m*9uH^E*I! z*Pdpdx9wmMQ^UNTyay*4j%5uWo z(;0E7Vs{%|JxoMoo4uLSM}9EaMb-k-qJN$0VPD7u!OSJ&C%-6%D4r--C6Zi*PIK17`%5*MUgSUP)z;{sS4W|%hR z=X)@BMUFK2jy~0dc@X#*!reF-#&9JOwFkW;55btRV8C^! zDJlrLzCclG`lxEK$E}{8JXImhAC`w(;q){U4WoUtI^S-_P| zOOeS^@7%yBaI|xoGCO88ai^LIx({CHndEdae5Pg%YyZhf*d&f;2hh0UII~new8E}J z6Q`5+tfD-dujD5L+HzLnLa`DKYsuwmJKB~liNhyUktTjw`hjvOyaS0cD&yzorcfeF zB=^9wSat0bC|HVovv-HnfJv5cqk`7w78r6=(x0csM`d8_-AB5+4Wy|inje|?6#H@;`hYB>bm#gk@NfF9_-suB76PteQ-w#wD@-Io>d_?4#`HuEGV*Mam&s0>Tz-*yOrHTg2ObJ@ z!1xDD1lK$jSa^fY!)8Rk+#P@)YkvhYLd_otTO}E2pxm&6VT!WzU~=k2S6Aer+PIl)NC18P$>m@Zi(CzfYBuKVLT6 zA>Q`qd3V{&%jB#&;tqLUW15HEAwXZyHm#G(EhuHRx`5*Y{eLpkf@)hnmXmE_0feXx?&DDh|lWrXM=8`4*veg|%>X=eGT58?= zv5tgeoVv2rzG0p14?SLoB~^Axa0t+a=+(>UQ3%6764^$TB;R~A+3e##sc4l1RfaVN zb(p2qMjhz4FA%KYTnw?6uK%F%KaZqRg!0iBPzUD9b4Jf%!nx*bi}_WA zU{iB{Ri#-K^LOb6y*Gd59AF7bCe=5-qf(iwQkmYQKwTvlnyLs!cgHx={Gcf6!S28g zoU6OtLz3Y_*9%z}AY+6Q0ls&%> zzBn~U4!1pYL-}b_X-maCvZHN#8M&{!6%%c`Rzg;Q>|E;dF0t;$mR2!|SloX!vM?^gem2;b4b8b;{l4z!KqfTl%sml_ z#Dv5u+%w80T=V>Q^PZzF3kf`;0>~z#)-g_M_8f19dg-SDr8xUK;KA0&{?yfAc!jbz zdP7oAl_$OaR4%J2V?w=n2dfwdGO98I95#X<4kvGodiYra?5G|Zn0hm4W*{#PWSwY& zkp1KY&QsT9m6%OeIZLu;9FouibXt497t(&a25EqjrYHo z{CXixcijSg%#TN6&Vu?NtP9)`HXSe0PtQDhkE>72r$<(Es2eEe&u3>(y&_xl%M2LN zJ-#F&FrHg-0_91sM1k)+ivVsJaIculYG%lS^FT(xqX&Js=MDUGUnmfsLdXN-o&ypA zxnxW(0WYx6%>bqldHCB4J{SJS?8<-fK0k7+OWWes5v_?QH}6Fknx(#o1wQ3}Z}s8I zESAP0!umkF!fr~vo_j%oPvF$g5iiWU*Hb%710-n~6OAA`8Sgk&NB=!f-4fS&UneNET6#bp8&+q4`4IB z>3OHCbvUfe>Grjq0h=Gx?FGD{5$eH%&3~;=0T9*52h-#k>BE(AU;6tk`>^WzZ~zTF89y%Xm)d|Gw&ru(o3AZfaWQ*XzP6PG7c4TyI4i zRY`CQpR+wUT*BahYmB!#K{KWC_NQ~S{PPdz$Gpeo_7G}x>q3?DXn@R%7euoZh#LR} z%-5%k2L+~oIRe1AT0YHbh3_nCTl~M_oO#jvPInC8?f7RZ*`&pqocdGlVeUKp{hf85 z=J~?~Q^~ppMl)a|3ZCP49X7{t(5g5cG>P0Rx@%xz<1ztsy;x{I2_y~ zfD>6omp2mnzl(J1GU~ql6tL@*fIl8nsr`4%G5GIozvaQNhcE8cr#ShJ)s*z)sLTCt zR7&rjyet8brlwO5gn>6WlLRnS#%qY~z}jQ4o;dbyt!7N+a8PA{JKsK$PZC%-P>BRk z7qQV;83mNArT%YB2}-Id>^3mQ6AAe<&w#!7d5!`QJ67p-26z&0#DLlTN&$X7Tlv8Rfrc?_8HW&%C_o|v zrh`84+;hR!Aks#P9lM2FL=(?>E#0rojqUjyd@VY{cZ*W|W@7>7p;>gNxa@w+NXkmL z!GSVWF851sod)&C%sL723_TECgUnjV$&R#sa;VSo8&$Uty|dg_M3c*3i05 z&_*uo#~0l+~lk8S|CE&->$`u7BR z2JrsQ5`*%gbT$qiRP3F5li@2R;k%^2cLB~p|E#Ntn7X^knY}ZKz+qG`Vfi*51!N&9+lkzF4j@EEVwczPNX5G^(m@>kyjVU_u-#tA%@V|Y2V-i=`c1NvtB z6DM@MPL(-)O2eSPnPIHEFp$njqD&!S@3KRd`V}UWPzQ-gf=ZvamjM#u8I8!cxD!v; zs)z3le|H{W%FZ4Fi$wYeKA-tbuey zBc2)y;1^SZhsA@*EeIPWGJfp`#NP&!?}D^`%+P?A!_EUiGSfZ?cr54iO~g;%QirL# zF69e~)-Pg{E2MM1CU$#um-e?F;%Kw4E;8@_PGeQe_dd6z(G-SZQ$KgtUPwnZ-}TMk zfP3mq(G{$gx6odxr-k$JjkEQ-xU}lL$;g36^V>s?OUR9DGqS~Rl+Cf4kLi_re~9L! z(o6t?kDn43MV>eLUz~2bE&i*^GkwklV82@oFNA5PwDak9A98fVS3*|ni`%KQ&Ps8e zpXY!pW%81E!<&&El|Tz#tCyC9eZB|&N-bgj-)39j7xF?;BNcTIZZh9S4o|Lbf1D;P zdhCUZS2}17bCD+fvb%3cC3^_!TzrLEJA|!<*Hm#oiar>d8l8;Ta27ELn!h#{x!sdz zoziEYrZb=OL@oKaF=BcB$5iyvgOXT#AAnYCC1O51aVP-)y*R}^XGVKt6=y`B07D?|Fj}}-3;owO;654+> zC!P${)1DVp*R{Tat>{h13HEFL6-sYrP^Mt11TDcZ z7g`OkCQK@H8qOc>v?U2XC0Qfc6 zJN7>PjLf=ZS10}EU|y>w_vFW?V}4o(zwS1>w*WNWX0;ZF5#XSS2L<8Kk8yr>&8Rg% zFR>h(2VjjkHT<~Hp=kty+<$aU;hf;mE|3Ntwd^MefdZ7rzfaQeiRC#quay?gnWc(( z9(FeiPyJz&-?0EM&J!g8u)3A1=*^Uc{D?=++QV{_BR~l&0+2w~r7WOi_l6d%?VHL} z7;Q0D&EnN4YyY<&mS6@Abbj2W-~)+K*mGYT;G#sRi5L#vS`4?rw2Z$NxgHiSCFozb za=EXustT3LxU#3<&Ch#OZfE#q*ps|R6UEk)^`ZIYZ)>afOB%nJ6i{k&HP#jn)~&wV zd|w+M>E&{%jW6CuVz4c>*!{Zbru&k8pM8gSDj%Yyo-zk)#=NYn3=S2a9KaSBZrOWN z(pO~T%xbyXj|4bwc2-s?3nfbfYql=M3$*)@b!y?3ZXhyc-B}#@<08{a90VIKwWLAd z^-s!cn!-nJvzQ^Vn990ZmE73UbwYL|gvF$d2Pts(fYVvh4vt|=hQb>tb~?-Y>$SHU z_@NW{J$XNR#5*Q{uecNa;kxl@bLVFYi zn3t2%)aZLf^1b{owTIOmZArY>go;j^AQ}T3doQmLf&0Um0i@M;brt^IWBb`V-#Ss3PO>t+1oZUrcSh?o~kF~dUJqI3*iMntbT zPE}-Hzx>su7A|i5hHBvCeePbB@@~y5ueZOfkOe#OvmG1AgLzL0o3CWS{NQor;e!PI zC4!*R>?bte8D?4}LBQvqDBAMa3|rz9HIJHgJCb!{1r3AqOl874woseJw3Sc>ALj2s zg89*ui(F;`#nVZkzB(sS)u=3!HSBv zZ!ep70w?&jN&@7!WPaavwx+Q7*BLs+0KY9{I0C31q@Afirtt5U9CZ)eMv_Sqf}C3F{KxUzdXRfyWIAE?IgFElxyz#FN8ET6>SoH7ZSF!l zIZf73l8q8^3NWN+UViacbpuMG1p}r^l-py1|JG;{=&wbcY>fw6e<@sxv@1RJS;~|)36HiJzGA>>*!^^|An~)Oc=U{pk1#!<$A3>Nv=4PK{q>hp@jey>szec=Q6??^^)gtpIELmxAj1&s=bB01Vc#%A(y+a>p$Y zdDpkJ`gUdu-20!N31CBqWy!f8T+H>VZdkuOCY{^B6LqjQ=(wP%HJ)>_!zEu&he;L5 zd6VMhIH9I}%Ip)#YHKR_m`$}5vcdOYtgL?D--3jXa8Tde?D8L82F56^d3L>sxW?&RE44|#oXAJwx34jg?tb4(%ou4m`ytGHqZ+x_2b9rDwe68T#E_eWF zs@a#9xmp9+jkMp2@Q7ac=#m5aptdSg^$0xnF|!Bf=d~BMhCA@WylJX5ykNX)AM|#S z_%~An16(emQ ze0tB=mx5=BJ8%HL6}+S*8JfPeLt>o0246j%vVS0-EMf9giUqhA-X)Zm_b5`o7UjRe z5pcUe_lWf7uj}SNWj}L~7QWyiY0|CjeEvzKrL(JSUj!SiqXCd(5l*|mbwg5956l}* zLn(E8uxAw1av2^C`xS%_$T}l?l_#2S@)3|CQ+4avPX^$NSQrE?rQb<`3_{yFJ)*@Q z%|E_>j*8osS>#%Qwz27P*_C$6MT=C6@{(!)SbZn?J*kl>jU(*|k#1ls|)YH_%sR zN60(R3SQPE+nry9sjUZf-wZ`==&I>40l*!tt?hZU&Nac!RR(%claIF@_fNR;}as+J2d)ni^EIC z5F4laI3grmp|IC>p@|<~Yufa_2}s10a`JAk@)@{M`fvv=v-1P+Ww|tMJd1u|5VIn& zJdj~7Tkn z31fJzit0SR@0`=Vk$d-k5)lPe705Th4J1hET1k9VwZfV~2sSKyV-h)5?vF&j84J5_ zJUJ8GGY0!}*KR>#nPd04X2$KrxG#rJ%mWOIiV2@m?^T~X8Wc99$`@oan*GCW^lZL5 zdD2Y0NV4Hdb{Mgh6YAvO=x*36@s=qDLe<`tf3Ip>HWwwm9f1qR>EpQAgP3=e@pN_g zT=@K*?vC7*$8V3uc)f2aLc2cd2#ViMy!ru%JbGNl+g=t_yjFGyEe)n7+h?f9(~PCa zZoQ-xRoVk6th1r9?yK^l4m~Pn6p_ea$1MRMesxA(-RW%+Rn__Hjf>c1LqJGSALyus zcsPsKh>S`_NO!CI#x8zU?cd#?^!4Ds2Tma+V>1;&34el!mF8xmVuXh z&n?z|-7ito^Z5a3CGdjwqPQ)&B!7nxHD+okszSLmqu7vv`u(c>h*@o?ftG>}hu*QKNpB~;=lg_siQ+GCx#5N#8)D+O)(^^Oo3dPPuaO7NPXVT7U!6+iD1GT^ zZ>-4ZQ+-N>|L!EAH~L>>-UWXubKMuy=wr^ysPp^VWV|)flU6$y8sYh}(K>0!)C16^ zi`;aNASgsjKwA3-t_m=Xe7Q7v_e4j$UQop1LGeBeSxu}DDMa(2@u~ZB)Y^o9j&)rp zBJe_nL_@+zjNJo4{;Sakm9s%vBlHZ*w{V-8d=O6lB#;~D?b6R zXn-pQ*uyGKggpP|+yqj1)=nlzKe{zvt82mn_qm`5-Q&UTD^vAbEtWVEa?u7Sm5E3&` zJLO%LG-8#x#bRDAHu=b}R<39EsEGKo>FegWU73ZqLD$2Pai_3^IGJuZyH;GKt=#!X ztMbridU$QKrs@219BS8GSGQKWpdzHru|LX5>g{M$0rRE7cf7BR>mPa6A;0`$bV~j> zGy}|GH)9Gi%2(@}_K#CiF=|_ppv*?B$GfI41j&7h^*&C%p_dfCT8>|yz-$#X_n|BQ znX|_q9c&WMQ0$+H0sX=p+*Iw-_^!lI*(vNkQA9tXgaOCxZ_E7y3Q~=3-2EEjwBde@ z-E9^cb`AcFD|uytYi=`=%fTpCxRJi~d#M_0qn~`-=Q>cxy`V%;2Q^RxQgo}>klQnW z^Xux0hB@zzbv+pFNGYr1JxqoL{%0300XJjTD{+p71i+TBP1P->Prr9X~^}Ec!6}RsVg*a06w>js*S>?k~%b17)V{d$|l^A%$ zPBBiV|2@mCHK_~)`fbM&MebWG2RzM@UjRUP`b%;K{pKz=_;|rLYs+=5u=5N#g1PhO zHQA4wrWbb60n5;7J=G<{BfRCP)UA(^B$}Mbr2z=w+<45Hagq@S?ZQW@@$s7~$MqpE zXnic{7tVza!2Ya*VIGYkY)98#KdC3ilAqPxmP*k{x!Wz5^SiI5+@UA*S?sb8+4bSI4US&T#h(E{-hM#la z-Y0O);Pxio)n_Z;yS?ZHH1~Fy1F7& zPFX3ed^J>%IMCD()xZs|4~*02^WdOzeN8gsA~QK=pEE@?6NijCA?t<942X0{FIJ*R zXm&RbRu(ZDP7L|ao+7Cq1a(gk^Ss~{gVY7N4I2y025^XNN~4;YT{O*8fy1NllZN?? z_wtZhgD(b*Gwzjd-e_f$BGH$-^qSx=n{S|HOXUFz)7nw3{V@AOC3^Mb%E7qw*yb<4 z0Xfs>v_M+Z0`bASkg74-YOr+D@5cH0)f8`%gWPq15U|Wi55Y4# zZEPJT2na>=$mR=@E7w*J;yd1Ay{zxohI99GH`sNp4}vKxKj&3HBnJdZJipcG(0l*g z4nDtILJq55mlU;Yff1|6}7mJiA4N`8F zqt2^Fv!9whiVI>lhY)gRE69@?4(#Ly2re^C4x&9ap;keHK2mH3Nv!1rWn}mpsL8pw z2~MFsWTOiHy$H~}X$(G|pvcL_Hir&XVz(19`?wD=NU@q`=tr&Vqaxok<0kz&IYp=e zEH%}fTIm^76aUfRoI*8Yu=s~-JQ;j>Skxw6TO51h$OLVgLx3Y1Vfe6OfV8{c>e8F~G1kZF2vR@Qxy!gle5C26IEXS;ftcu8-8@7O1 zkK&WD4o%Zv69c~)HvW~Fbapq_s|_UfXmFr~{4_k&1T-fCefI*TvPzLyM^FDXeA~w8 z;#@uxW}5#px>53_lu0`InazRgmSi$fZIkAx_|M#|;fF=avq9(-@x6Z=YGZN{6d%7- z=&uxIkZWEg^5P2!-w3xp%24G%O25l0jbMIY zeMCM1iwOa{Ym?j5Xvb@oe+Guyuo-!b6w%qFyD=0cA%+u<5)A%1VHE^Q&fqd1Da_tR za#TIJ$hOJ^voRU9=xa2sv-{>UX{m@Ib8@=}wk*Cj^?TSfP35Hd_|=1_h2_Q(|3b>M zuj(C_5h{q9nNFxrT5Q9g)BMx6Q?C6nQW^G3j1a_CLCt$|tc_<`C^!i@fWNVztn@40 z=G+}Q*itrWqX1}s-KTDJH*w)Jvx9+c?78}(bnna3GIei>oO z5jJw~b}mh}1f36(pXcQAB-3z6h62Bd-ZUc@HZZJ?cO7^8{!2q6pQgv%dL-!Lzm}AZ z0=F1)eSvvH^+g~AMI7fhwVf4Z{MhjLxAclk)KU;l%*DvopOit1QpEZn zjZTT7uc&Gawu2+;JEyx_mx3YuaZXjr`|fNdkQ<+3G2{ar=~S3Q=G)iB=c8X12Wg6_ z{j49vO_e++xBpdb&Y6srv;5XKmVB670^Ai0pu=ZBoE1H5p{thr0}<8EJ4A-Fu6Epz zfKPxDrm} zx-`1_%JJhk&%Mhd{v^|-8$%B0p%+Bv=4|HP#B>PpY1g)Mem8$;vy1|YXmrEo>2Z^X z>oS*A8$qF_&ZS7C?^r{~n@K^bXzf#+cLaf__I~AN6r$<(*q9cOz^4*AyH&qY)Qiav ze;~fOArm~si`;cNz}+4jD+($iHUfF(4N?)GCr93nB+nDSVH({q=g*czpDdR>%-!yJ zW{|n76A`ki0obx-H{FVO*CL+@p4ihbgKfL>v(hA2xn#D!x&j?f)gEIPYyEl@8EuF* zg9kzD|4}#aN;yp};{nny1^~@I!OcXry!O8w6mK9;o)aS4JgS$DYU9v6o&y z$7AoUK7xgiWTBJ$UP0*RV7F6J9y286=R4B{3X>Au$(X%mS0qbwe>i6`Ty!lr?sNMQqO;n&8{b%P&ZMoFuWEgBgoIIu5#prDI!aBsUyIG(cx+q z)zL2CTE?Ysca(eW&li+h%;OKKIM;wHx7zeOjrGoZTE}?9AaIiP!(~tVXwW2PVQwu_ zYsXHQ>#T&{f%G4$o?D$cfphl zTI`IwTocxc{_L)j))6*pSKx;%ja`veuH9ku#HZ+ONy3#TIo<*=uR6%gtIem7%hSm4 zldB?Yt}4Jn5ailL4rQNrsE3lMng`5wK^^ z^^02!$|-wR5rrjKfc@vL4i=xYe78-P<6rzHiHj{`Zb?*~+JJ7YZ^#N<;ut zkGi+`H8tA;q)W}{PPtoUrPn}BzR;c9svqB1s`mZgT$cJGRDs#Eh^f?`fy%Te#sv}itP z{U^6p713J*pV-#b-NAVvQmwQs;~#D|91tpNkUm}w@Stam4at^@Zt5WG9m22jRt)C=P%WeTH;>BJkh*cm z->cjAD1{j7@8ELl?@@x`GnAK`6Pe&(c%^9<_OVevICZLLmBezEN>siIcu}}8AkkW~ zLY#H+p8)|`b(3=vdDChcxXw6m0YJAOj6?QIqlv6P9e z5}I?)fgO2d&<u+`V;G{=@)3S^(9errv;h9!I_w$c$ebMt6KlTL8wN+I)sv50lQ) zg9Q&9w|@psv5x3BVSGYfc_JkC%R3KJ^(BI?r72)1qQ-6;JU|24LO z7BO8%bN#1dikKw)QJSu6YmF5R?nu2ImuaBa8MhQ<9WtDOth|j57ViVG{>et#v&RqqUdnR=&BU3u_4QTfL zB6}?z0bs{FthY*l)Z+ve88m;9E;W6Up}b}wEO63p4bXN9{kQhwH`xoapnthj^rr)} zLa*L$+%_l!uGSp6fkZXMe{;VVG?}hOI-EdSwL$D^}%h6Y(D;Fzns724tA6hD8G~j1o$2BNjL7STk>uSU)S+? zTc{r=dv;HvZP$_~7`yO}Ii>dKU&MT1_kj^9KO&Wn!b9{$p#|r{U7on{oeH+3TECLu8tUYgWUxVjUe@p*92kos$iGrILE;WM> z6t-Uyj+M45!L}$DqeAAoJZ7;*MKf#yZ|?4S zP5CJ=ep7_^S>hO%jYLox<30T6gEZck$i#>sBUD?J6nHEJ5ttseC0lCrb|?^eH{=>_KylkB7P!9f zDvD|V3$q1i@4MbNd_oFC9fioaajhwh3*l>1v@-1XPsOCiTYjF>N~npIKF`gQRi%>; zHB5~-DCGINvL1i(3mal)p{Yl>9jkfq95wJ=xjir1tt#(NEE}bWk&xe|_7Vs=UmBgr8+jeo0c#XTRzYok!5!;}nH+a|?1W6c=c2b{39zosr7|Cnx!T~c%% zjBz&jyb#7Z$2Mr(OW9qHRhPg-Ur1}BmXkX;uG{zWC8yh}b?lot?bwWC8c(973eN{3 zkp?CLZj8?cad^}IB5L2j6__bAZq5XreDuOM{Sbh z$fXyib(r_{i5w<+ELGyA-S5YC6!>WvQ1^e%7bcIlO{q<0s9Gzy2L&l$3~n70#MroIRFj%ia?O4vL_{5Z+mn`R>X#?OX_krHZ*a;OroQ+r5p=H|T6o+LJv z^LpYQ8r5+Crwj_WN#sRypx*(vN;fYgQxeuMEo681*c^)ZZ0p3q-JzB&OzCl}Q{~j! zMX7z0cEFqwhHCZFlSP`^MEr8js`KAJZx(R^?>c&2dB$J4`;%qQrtmb&-t%9fH;HS?r*aresY6~3v!@|wi|W%)ySdU zxPj=cHpzKd_gO1vk)%^rI>0UjyX=YDNDSR>?40p^eZv&7epKW4?-b;#nbm`-_oXLS4@0y*Im8^A!vpjA1(TOPnt}yfu*{nZW<+LafvZvv9ozfXehHq&hrGF08T1*HU1p8dVqJ&=tAd z*_cO{7UT2$7-^294Fmt3MVfj7UhKTF4

z$@Y6hQ^XF7h;JLfO6*;RWR@NEX!&>C zg4uIV-@xwwQ1{+ZO>N!ZuV4YCs|eB}AWga;h&1T}QUs+-1O!BS?+Gd;N(l&pN>hrU z^xi?bfDi$tOQ<3>gdP%-yW)Ay^WO73zkA2~&pXDw8RMLhVD`@1d#yd!TyxF&`QD~Z z&=Y1VE3(@g90Vy`x0PfsXSO<2xa$Umodj!FncxDwtG2`umyOVCS`p1hd=)3F+{JGb zv&Y$8AIF`KV1PZ?bJ*ICVKWfSk_P*!i1M^BvNJ&z5&jA%yccR{&N0Elc?4)(L|$0ssC7S!XBa%StoHq8 zh_#Z-i!Is3TWm~c%EvFBk&&2_(xyh6`Ab}ihq6k3^rn8CZ?MrrKS)X0B|<^{t;AE& z9YFX4OtKUh-8wn|B4nN$%lkHm$P@mK2oND#$xFPU3d)ocMEY2X?^0U`I=dmYR1X#E z^(Tazchxfz2M&T(j%5}ST3J`t6Y;z^7TsAc$|VV-&vBiF8U!6oYN&o^-(q)n0JQ`z z7RuTs>376SXUZB%l z@UY{|7VFYk;RA)@aS| zEeC>S!$p3JtHti9;^{F4mb>_2%+;O`{acDWGN*!8&ieKX{IDS)3K;o<2s;J~srxNS zkVUm1rag+?HjS&F9h6+df_isFXeByqDtS3s7upYJw@=M=-_*K(WESwylKZ!*HY8OP z5x!afL^2L=QLuNPNyHN4@pkbv+oM<*mAHlZgM77xD8ZP+R)wg8i#3D*%Pp#BmjmM@ z<2}^xFUarbk$AYoAva@Uu*m_8jNyw^JDD9?N`vq_Bx--A6Tb1pS9 zRyNEaq!8XWUW5ulw_ewOsl63Am+(cy>U}8<6T7Rt(o@#oQ6EXLwJTajMx=}9+HuD+ zOc<|TMZP0sehdvx_DO*3Ky#3Qz|6>2*>n6CtAduu*^|9=+;*(WzNZ`E9a5b8>eAYH z)z_3s;<)bDdL+c7)E321jsy;|cW>a|;4&R%g6g^CU821UFCJlMbm5qF_9tT4E$E1T zOhl!fwcoKrjV^&=wfL_G+N7Cvmfw&^q{_(yzKauz`@Ap!^AP%KH_nIZz>`UgL^(0edL3*yO$L!>~G$ZSgKoD1>7B#4pNT>a#7FE1%HZQecVHoJr;tK*|HL5HZ#AXH|f2PX5D z6dbSch8Tf{qP?LEX{d4p@1nlE%+Y;;Sczi1@so*FGUv zTgcrqpk;{6jd_zkuB>U$%KBp|Z@Q?YQ^@ zN&jf*ZPAFjo`ZUz#@n;a8HlEq-)M~*%Dz#o!rf#0 z?F(=G3j13?svmOxex9h+YxZe5kNj5e(yqmb;%_!x@;w|uKbwz+JV~VwLzJ(-p~|tB z&W90AkAv+iPVd#{e&2R?_dQa?EFY7+CqM4>Z*Z<9_!Dgm8uydd{eRk2o3=c2X?=N8 z#&OUJZ-T*$5s$+j8pOdzG6G3~@-AP>sx?N_U0=kVXD&j3RomDn!gq<9#X0O0c+dY$ zI6PyNbEIqSe)wA&7m1M32b{5t!a_wiJ7ikdnq2$4ijC(J`js)R=I0!A`#)G&@%peg zXl|kDzB=qw-Y7cyy83mR_;$R~Vt!e`k-tTkm;tq0V9o_8ope|R%_IQ#Fl@bUakgC; z(s(+;I`Z`cTSJ@UbSliJSmQo>WmB*>v3`?OwY&X5jpbPp&22_4iTDLhy98!sRaSK2 z{0*5*TLwFNP4D8_cAlpJuW2isnAx(%q`M7K?$laVXWhQ=_S$-QG0@RRpN%0{b*n}= zYWd8rxOH%_4z=yrA@+8*FSwW-GR8^7Uv<)o|ApON3+Jx1_XuDHmrDC>@z6j1Ee(rirO9~;xhbnLTNN$-(_he#0&yogU^BbvL7L|I zXWgPPG)XL}H#+D1k){jEPT7Rt`)%3Z7>^!2@~#yK4U1-6#^l#@7#vEhBq*^sao*Ptz0R>5EP1!@q5(t9%kzEFCMPNCv zDF4kM$z?=pA~Cc}M)G*fG{YEZBi+tt5$}jsC?F_~M6gC;=8Oz& z*HLg5^&2tp%99=bFj8UJONG45=bf(9=d__?ztr-PtjRsb6S7a;pyw&qj~$oX=Sx#i z_qa*Yn+ChGF*ek`O$u$i9?zfMNV%q^^6=$7lH`R3#*@Fbn7RUS6?IzE39zo~Td3h~ zv-57rTkeP4a|5d^h)mgP1=z~lZUgwusRasOIT1)(6QL2VZnz|IAw1)8zWYunE55?| z24mKr*`k3?@#6N`NC*Q%48>RTW#vbiF`cmUe3vHj&^J%$l6n*bcRs90XxVah2JwXl zDtVjD?c;o}6N75^Gh_CZYCs%d=A_D1Z-@N@?Vv!h2Ud1i140f`^1Mo*0?C;i3*sfc z_8!6*!FFQBE}iK6K`0P2AGMF(Su}5>OCX0;6x2$&_O`N#_^Dx=G`r@m zgT6ivZt7`V##Z)JqsaD10C$o&S8ScEt9WC^weu2DA4r=x_U$tzP%DGUzWV8IkCCM2 zlFzKDW>q0d$OE@9$L_)dHG{cEkM8~V{|V~`b>8 zjA4}C#||mF_BO`__($E63WZyeJKl7Wi5@{arMm1MYi|J3V+&M^oO0*eu7U75=apCw z3#^m&8`folf6X6?k6qPtk$s48JW-R9wQzQ;ViPY?if2sH{1WPDF)$wEu+L=e3G;`>3VlZb^YKn zsam^hR*#Cll11r$vl1!HIP_(pL|}zU5}bouNvH?ixCuRlZ}{3ep1q_IJ2tTW;ALjR z&yb2xxgLCV&(dUj8{7&-Fl&Hismqndck|VBdl{vqO>C&( z9;Z3G<2W<`8;6b*&lxnevFj@@qvmeD)fYYHV!lNh*Vb_uA&InyvAWob=-qgtQNmA3 zuO`SJglP9;rv_2()?+7?SPYbg3xfFIO%X{p~%sP=G?`O-{{r-KL zfv)fKYLY`eA24XX<`nM&#zh1+6b0$gwZ?bdD%siMt^R43G>e>_u0>RWsuf((Iiy z?!aC76OxfjEiKEmHb;oqn+VoTg31KVoi!r$t1*v(Efea_;(bD2a5l|+d7fkmp<;_+ z@B-yK!}trTp+dPtN_cR<6pZQVCCa`aSTA3paXyic2%d6f*uAagnjAb}4qb;N&_GCGBzwbiLp&f=y` zf4|@Kx)c@wU0zsZ=-5YYM+M}Qrj@&AZH{J$+yOT}2e6N_N5O=O`^@9r&2a{}t=k|+ zeIDE0`UqJn>}DLmZ^y)vC*Relyn*H%>fVmBJH5XZq;secv*YlirxfK=%U?q54%Gbl zMVM5$6JbbiPBR{_iro&iKB5E#FF5AKi%UXiEdBr&o^Jx2+d)#Nx6(ww)WGk_<(4_6 z$m(wYoh9=lS&%7~*U`-%^{qvM%;>09#zwiO2Q)$EiPN%!#P)#S^2?TI`q38hAn2Ud zxHXR{Ec^VP>tQU!^N2Bm%n ziyYu_g=jaBF4dv3(stRqlS0p8etw53OMNT=vXZX!-PO#|nJ@wCU@uP9y6bLAG*Wv^ zfB_(_2wECu$1lJ)&y#VDWohiR!{yM!g-7~oq)gf7uMCGI30NcCVHSUv+bTu2c_G$V zRORP(e?MGl%U$RA7K6B7L|Z%IWM-a2nM6;)=^llLb7|wtgCBm}0{GNz7F11yMLG1? zv9M#>>MD2ZqAQjn`;?##5LCv)bB?gr3uu;gt^j-dgApwm&ygr61=UI!5ZtjJuQyZ1 z!I+#MTVGLLOfpV}p8x!o+c~}6-vUB&hYk34{1(SSyEns@HE*H? z`!gzrV)>xjy3c$Pp}bo7=OZV(D}a&<5j#ppIViNK3pH|@Q4vGYOunEL&scya%c@Tc zK3?8A&28caMoJO2nba^mIp{>)0S|C@|95~LWl_epTl)Jf#U zS<-8bZ83HKLxvzN&&@4yRB9B+oyLF445r&%F+x8DGY%9)w93Y31I!E{`N8J5F; z%5$XJ)$%Wt2y+yN7s9o2Q1cHF0~Cg%CTiRve~1|0-alIh(${~881y{VuGQmRpu8pX zdOjBbTiA8Qlz9)xb$7+GvyguvH^o|~4+Cm#k^hSw60neLg z@yl)bw>MfyF#L8VsgTz<;t@q89lHa|eV+tT>&EMV_y7h9{OIQ=KBJAVUEzzP0|;~2C8Vmkl))8F67 z>I8HT%kqcM0d1mP2`@sD&Dzb{`WNowE)5Nf*zHl-zGTs8fX|p&jCKyZ6X+3>-)n$K zwjmO5GN53(N|{W1=$xwab{#1+2W>1oZ%A@PWpI*j;%h0#xQQr6<}X0>17N@Q_%0blK?Mns5N#RE)&j(mznXpL&d$mY;rr;@pqvccw40z_-WAmI*EkJ- z=v2v~tOXmbMfV&0u1cR7q)Y={Pz|c1^K8k;i7bojAgSFo<-T9SEz80rj^MSjYETH@ z4R|YOVT5_eBn1@*BWP9rF>>`xB^^svK4Sd|6<4W@>%`cwfifq#BQt_GwEHl)rOf|Z zNB#yESKo{SY?XO1uB-qirF!L4!2}=?fX@_GpmG=tsLAi_Nq{=-b-0{2IpJL5y8Wv^ znmiT|FBX4iBdbNL$5eqjozYe{eXV}s3vMNs=<^4=0`C)$;IDVZz`K7Ra1U_p|37em z-WA(m@QjjsWeONKCtTBJ8x0_F{B;-ap8r|ztE=I1R_>mFsg=SDc zVJ`dYF(qSQ(mXZ6Q&H<-^M}53SEQRO*abjvZ0#4im5F6!-iOCG6{=iSR&MphGEi-I zEgeB8`5hEKxoj=;?DYs_=D0U+V+xUNmRbh@6SNM{9Jl*0vr!NeoRHP*8PkBXT<|=P z2;oNGaD%tf%QWb}{>nOV_-ig&TwlYbq=i_o_vUJ@%IH$)d2879?cxt-@4E9R)SO0e?;t0I~+xWq|rjM&rQ)kxlsO@S(D2U?+Sv8>w^% z#{9qjcehu-8r1<`H6G8bRhVztb^4iE+c?*SXB7N75bqH?7QGQV8H0MB6)&IYfYnl;*oW@dMDYY}4iaNAlW~l2UsO{&j*s zXW97}i5!vradZb~vZu9}hg1^Xf4~0mX!%cjj9noR^c-e)(9!-NpSYTBK-5}S1HZhl zHrcd2Aa7@Gp$OPQ|FfwCoGI`G|7|$`!^nz8{{5`}r}5>sp#Aq_@*fAOR z(&pR5umD@PV_))k>hOp0LXGJ8OJ$U1MyaF<7GYAvf8X%Ikn`us9H`+)28i2ICcOEl zSD&a%;MT%{fN%^L$zO`=ezCEW{f_T~N+g0e+03}ke10-g;|^^Arg}cJkP%Q})K-V{ zkbkI8hALca%G33+T(0fYFnMNz^fg&@NJW+Fw+nupkJ}S50u-F9PVQ>N8WeZ zBOcWLN;FW?UbW#O!)Av4ze;v@s4%yd=X}iosy^%z4{lntV1Sc&AW)a|Qn~0Aa%rH7 zOT78fe)GOH$6chgc#8{kW#UI%Jx0kgx$?*3x$Mgf#qjlO_-z20pwmu$BkgWYNOZ9I z-kn$&Jmk0&vYcB<>lU~wYFX*yM5svG(7ui%Rp23|sG|BU=s$+zUw5o13M8&|JU>U) zpDzQm=J!zYOm_F(_p=JbgLx-505JQ0J^zkXz-$D0h8$6x_iw@f1h+7pT-Mq|DuL}5 zTo+7SreT>NDPqHmhO(oomZ!%3YM1#_Eh7yJSrIFKpdeT}Q|F|lwUxU=|668Kp&)=( zRTcr!OFuws>QeDlz%3*?9f~y{%w5mtxegdTuCMNyeC#U#GjyEhI__^iJSssE6fo+v{Y zsdT~`q^50W26mBa6RVa-M4K>vYLR1K0Yfb_cWQg1p?de7il2>F?0*j;ZguYjVcS(Q zq3Dww0I;lO0up#S;2JEW0sEyE(0m+a+@}E`hi|vIRq8zY+{PvbxHY+G$Fc{ll&MOg zLB)AuZ3lF=xgrTHPBXHzin{h)F&C>lFIbv2_^q#1PrB)5ZUa`)$NkjC9jvU47^p@b z0eFj5ST~$S%<44}pfASjrppPqT3M7o@I<t-9bw=G+b6_5 z!nf1&c=e%HO8O>;@4j!%1`o;lZ%kd!zf2%{TDJoRh|7GetiJLBv+v9wxYOQA!Z>+8 zk%q@9?v2$GX6tl>&<8l&!&b~K<E z0w(`iU$RGRpkWAHU2{cH#8L)3Ivk%4#<&)gGzSHt0o^Tmq3AjK(x!?_M3^hkiFyHl z`Ju$J#?5zm68J@{xyVW^r{^(T0Z4;)c~q94%|nx{gB)jW3V7JHNG<&IU#KPVNCP*sj)b^%?=rdudMiynr3@Y zj&JeP-x`+lrkqR0#mvGXK#rGT0MNEBMY3^Qk?~iK{P5PHss&cv2{PbHExKNhB zkYXM5(G>uemObC)L>=B7SJ)e~bIPa$IKKYGs!?lsJ*s6KxG(9}sHV0Z?s8i0P~ZVn z-}f8m(?k0=Z*w*X_3$nMQkwoMzi&{Yo{Y9KVY1T=@Hh03f@CI9M)fmy*mf$}z`PQ1 z$cA<9FFi8ZJlr3U$K=?2{~5Wt+0KxMdv+|5VD6aiyRx-JUNjEt!;Gk$1j4?pMJW^B zbAqAlG&zUE$@-=_qQMMN1WxbUvecp&yjKYv_@5(Yc69QapO3G*!||4{V|HX0@D8c2 z$$K<(?_VQg?Zj%>u79a+!fH<1Y|?%FEgYV$5!`t2a^15}wAumPT-{G6JI04nG!@uW z-T98iExC5*WQ0^1Kqh6E+1)}{i|SXAp)(Yd0w3pF^yXas0cCm6cXR@h5mK&SsL^mZ z`_q5CzQ?<0q9Dap@=<)cl-I*_)|dlvk}AP{v=bVmm>68MJ~%vm2;UnxK2@AGh2G(( zeqmJnF6=}+kDlSpdLFBpD=ajKHVx8+6HIq*1~EKOI&n*tt|?+V_@=4=1uf@GL$!FZ z(|k!1q$B--n){CS7;pUnrKsWQ{lF3L0c-SI^9qOluNG3NpBLGpnKp#NHchsArZsj$K9VF5jC1!1)Y)pBXwNP9BnN??N-Upl;QK5nHvcS<=~ z#k$By;8#IT1Nvh7F!%m+YNz*SwAx0KK)1X*AH2o5r-UXW7TFV``Ng(F>mh6nwFJ4j#!s~|&8ZsKrNFMRLZH_L`-BgGMJj`_Th<>ynmxxyA!41a zJUf}UVOs@X$l#BC-m914zuq`zq$yJuQigjT9qtwg*~%x|8w$^kT6y z%{~CPwdl>;3jpw!qsAB$E~Eu-)b$V5EPqIqiG~9#X`nKX^pGr=yO;33V8(EeUy9xF zh70Bqx_H@Uj$+Y>rdz_hPteo?la!RD7E9fe69?G&iZFUCXJA(-Vr!qg4CDAgYuFWy zYrm#WTmw0d=9i)nuE0zCGy?4v?FPsN<*)lGvs?(}sOn{#0T62B-8H~)#gU~#UD4)*(frB3c`c<8~VbYB>S({`V@vx9Q8 zt)tx6lllshp_n1vVO6y#2UNJ$PdL09qUdnj+QGjmT1+QuI@sM}WoLNE>J>hqZyV)^ zmvvIUG8%EqH9J~-Kvf!+iN2fp+&ek@RL;8aBKC_;T7l_`D3){d@>#X8P7FpSnpEqv zN+W*T;DK=AT{%)nH&9vojx`L}RNP-UXfbn&y57z4>{PDYmF1T8PL^R#@usBV`n{7Z z3zdlslTiOq3zso%r#Ct<5?TQ?} zW%>CSDK7$8GkaIho732^d#Z>~9RijL4~c%zIH07>n7M__`ZR4nr#z>86A{%^#8W-f z*^}sK(Qyj!{h(Vo#%HbM2^yXWSgkl&>*`I1y_shW4xTpVmm~KjhTwgoH4B92TZVfZ zCI%gvFmCEp2^&=a0$@^o4gI6U^083kFsUwubMlnM+uhI9mF$sx}{K;qpS1e_p=9 zV>Ok@gSgI=L|g^heAO-2z$hn=w7pF4!@@d6fjisEk4PP9&N z8^-s_UL6&k4ziILq(qz&$h=j-@k4ptqz)3V;dGqpgKlOZKO|Gb?g*cE-#yhD!sOQ< zIOVzgRtMK1&u&|>nh`3aXz?65-xAt-QjVwv%@pHTj5-jWB?aJj4lL~!`I#mU*mTQa zgyl++^3)=)pS%Em5-cl!=8h>Jtg-c{+Vs7NXl{TO)$q6KmUVp-K}3ekMx>!g#%IgQPOLl!!Pbnx)uKk9-(eqij&|eZ z%R`yfpT63Ln#(Bzoayy7_@^AmNZ|shP;WFUz40h0mzD_4z3si>p6(B`=EwE$8~CX$f4k93~=V^AXyJNy%(wyDxiMivhfG|CTt0UM-CW_1l`MT@=dR0@ld?y%7q zWXMRhtfh_y%k{6f7U-W=G z#zbFYvmmu+#wMi>V+`DzhmdL+%jFo937fP$GR0J_m#0apRuPDowSvMoac#!tQ!QUe zj0q8W3Ni|atmj^1 zvW!1z$;ZS_bIOrv?76@%`|Hmb@aZhw>)R*;Ds-s91(zn%@#LZoWz*OL z__yBO6Qa+@^1F5EAQ>kesE3X*JVgAcCeLC9x$7F2WGapJ%z0iNA)1K$p9;3Ovcgqz zy02!=znyD2DLL_(V!3S6eqN(Ex~dcy4W(COUvlVtf9&{opnjwiu-JVfNgM@6Rwu= zIk1&X15athUZ-R!{C@Xr=zSE|+c*%GTk(o_eX^<&5Op(ZmF5LcbqW+Fhfx#h#Mzna zb$A7QJ7aJU(N>BnZKub{jzYh-+=7cVm|ZA~xa8!Z;Ucd0#t67Q(@w&OJu@D!=X}Xu zbZdFcUgSQA24J39?Lt8dj%Rs!<__n}W@qEJZ-2~@ddD%NjhrXrh}RtjlT7L18H(8}x-c>b9~rjR!1Q&R%HB zf~We}6Mjm$#)+yZ#%WiucCa^&EZ3Y?KN1nUr-RETL_Wg0DcJXKL73Q=Nkb{kNn|eg z;$i32&)-Y>-l{){9b)3C4m41ZR5U$bcJ2AQe5yWrNJg{7!&`fVDpdX*;A(E@XaVl8 zubeJuR$~S|(`5K*^M_U5iHP-Vor}ur30gz+qtKL*8-Se3(6_|pd)Ip6tbvV)cdyZ# z#RG9Mkd=IK%)Jcl^iWrFG$if zlqM0jO*I_CHc!51>t;?Np6F0rbClD+>7qWy=!At!ehn1Cn93<7Z>yh(Oy6!JqZZf8 z(gd-I86ZHWL?VKzaBC=KwysF@k|9V@Z7bvn$ckPRe8P854vzQ^;NN`&`D9&Aum&W? z`b*{u*wPrdAZ8e&ehyX=rU6<#W!)8wqnw_$6gWvfdWSJ;OqrK4N zB$4(|;pnljRXgSBp_#8zsIQ(KUZr7>j0bm<0^}e!enFcRODh!p0K$<6Py57M6r%Bz8<0_Up01J4QkRw>U?Ymi z*;k}VxH;~s-f?02G}DQ$t63V1bqS~9X2qvRo|@_J)UOuzweZ4KJG9=+G)GV~s_^Uz ztEW(Kq0L_Itg~@-TJWg5q?&HMxvFdKkZq-wgt>E0z=6qkjE=%{ zI*!-P@a0_Gz6DEK=7lSWQfuK(G+XL@zrZ*K2K%sDw+#NW>F!tF#THKP4$6D48`G`` zU3d8UJFWTWmHkFho@#IbpL%^;JzoW5=YE0(^j6?OsK&6DCz$D=M7`_b zP?0FJ^W$lrT^E0B6K5IVw*X@MF|j{z@(hr_)0=(uNyHXh!MW62h7u2gX6bS6LvyfC z=KU5yCCS{zeN(*?V-|PqgckQudpmOr_O3}|^QQDD8`X5EzXhp{W_V&LGt+@xTCm@j zfL)eUU z>YCfHI%!}~3igvtF}$|I$>Qn2G4#ca7S&AGzl9Ts09tEG7kw##Pj?)de`eM;sRe|` zc=Mt{x(0B(p=1F14GNTb#7+VX2FOUeatav)#*0eel5qR6veU2ExxLg1yD((GZ~WXv z&+UfVDJNYH@2`@$gObp7=l%gmmi|p(Sc(@sgKyrvuqc-NqtntNtW+wQVo|*1I52Em zj2Gw1M7MARor_OF=E4G6L`wIlO^Urm-1V#{#jf(4_>1j{w)+4XqbNhuFT)|pQ-tyx z)EJ*<;VDF$tPGxdwH5nXIC*F4oh^E6tFPiK3_20aNg>LuDk$MCMbPwoKHy8qeNkB{z8-}b& zg+@zERSER!exO(x5r1Y#?K-~#pwugq39Psa1 zxe{o*_1(6t!jFA`lZfV*yNqHx;6vI1_fjfC09L;I(rHg-O7Uc=4rhyi8)`me1TzOc z63oONzq8+%871aQFtyGu7c|E3bG5}OY<+Q9mIrb4g6;&^nuvK%c}$;3#qGUn=9!PN z(h*cw8y_UVPN;;*UOjQ&3bOP-?(SNEmNSj~bby`3@9=Zwwc≥}>pVJ4D2U#0E#t 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 =pGy33r}HQp!t1u*F!3R)r$KBoaFI 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 vO@f-O)wNl7MgPwR{{IJz{}vq(Ft%VR None: assert isinstance(generated_tfplan_document, Document) assert generated_tfplan_document.path.endswith('.tf') assert generated_tfplan_document.is_git_diff_format == is_git_diff - - -def test_does_severity_match_severity_threshold() -> None: - assert _does_severity_match_severity_threshold('INFO', 'LOW') is False - - assert _does_severity_match_severity_threshold('LOW', 'LOW') is True - assert _does_severity_match_severity_threshold('LOW', 'MEDIUM') is False - - assert _does_severity_match_severity_threshold('MEDIUM', 'LOW') is True - assert _does_severity_match_severity_threshold('MEDIUM', 'MEDIUM') is True - assert _does_severity_match_severity_threshold('MEDIUM', 'HIGH') is False - - assert _does_severity_match_severity_threshold('HIGH', 'MEDIUM') is True - assert _does_severity_match_severity_threshold('HIGH', 'HIGH') is True - assert _does_severity_match_severity_threshold('HIGH', 'CRITICAL') is False - - assert _does_severity_match_severity_threshold('CRITICAL', 'HIGH') is True - assert _does_severity_match_severity_threshold('CRITICAL', 'CRITICAL') is True - - assert _does_severity_match_severity_threshold('NON_EXISTENT', 'LOW') is True - assert _does_severity_match_severity_threshold('LOW', 'NON_EXISTENT') is True diff --git a/tests/cli/commands/scan/test_detection_excluder.py b/tests/cli/commands/scan/test_detection_excluder.py new file mode 100644 index 00000000..d35787ab --- /dev/null +++ b/tests/cli/commands/scan/test_detection_excluder.py @@ -0,0 +1,22 @@ +from cycode.cli.apps.scan.detection_excluder import _does_severity_match_severity_threshold + + +def test_does_severity_match_severity_threshold() -> None: + assert _does_severity_match_severity_threshold('INFO', 'LOW') is False + + assert _does_severity_match_severity_threshold('LOW', 'LOW') is True + assert _does_severity_match_severity_threshold('LOW', 'MEDIUM') is False + + assert _does_severity_match_severity_threshold('MEDIUM', 'LOW') is True + assert _does_severity_match_severity_threshold('MEDIUM', 'MEDIUM') is True + assert _does_severity_match_severity_threshold('MEDIUM', 'HIGH') is False + + assert _does_severity_match_severity_threshold('HIGH', 'MEDIUM') is True + assert _does_severity_match_severity_threshold('HIGH', 'HIGH') is True + assert _does_severity_match_severity_threshold('HIGH', 'CRITICAL') is False + + assert _does_severity_match_severity_threshold('CRITICAL', 'HIGH') is True + assert _does_severity_match_severity_threshold('CRITICAL', 'CRITICAL') is True + + assert _does_severity_match_severity_threshold('NON_EXISTENT', 'LOW') is True + assert _does_severity_match_severity_threshold('LOW', 'NON_EXISTENT') is True diff --git a/tests/test_code_scanner.py b/tests/test_aggregation_report.py similarity index 81% rename from tests/test_code_scanner.py rename to tests/test_aggregation_report.py index 01234d1d..b09c4d69 100644 --- a/tests/test_code_scanner.py +++ b/tests/test_aggregation_report.py @@ -5,11 +5,9 @@ import responses from cycode.cli import consts -from cycode.cli.apps.scan.code_scanner import ( - _try_get_aggregation_report_url_if_needed, -) +from cycode.cli.apps.scan.aggregation_report import try_get_aggregation_report_url_if_needed from cycode.cli.cli_types import ScanTypeOption -from cycode.cli.files_collector.excluder import excluder +from cycode.cli.files_collector.file_excluder import excluder from cycode.cyclient.scan_client import ScanClient from tests.conftest import TEST_FILES_PATH from tests.cyclient.mocked_responses.scan_client import ( @@ -29,7 +27,7 @@ def test_try_get_aggregation_report_url_if_no_report_command_needed_return_none( ) -> None: aggregation_id = uuid4().hex scan_parameter = {'aggregation_id': aggregation_id} - result = _try_get_aggregation_report_url_if_needed(scan_parameter, scan_client, scan_type) + result = try_get_aggregation_report_url_if_needed(scan_parameter, scan_client, scan_type) assert result is None @@ -38,7 +36,7 @@ def test_try_get_aggregation_report_url_if_no_aggregation_id_needed_return_none( scan_type: ScanTypeOption, scan_client: ScanClient ) -> None: scan_parameter = {'report': True} - result = _try_get_aggregation_report_url_if_needed(scan_parameter, scan_client, scan_type) + result = try_get_aggregation_report_url_if_needed(scan_parameter, scan_client, scan_type) assert result is None @@ -55,5 +53,5 @@ def test_try_get_aggregation_report_url_if_needed_return_result( scan_aggregation_report_url_response = scan_client.get_scan_aggregation_report_url(str(aggregation_id), scan_type) - result = _try_get_aggregation_report_url_if_needed(scan_parameter, scan_client, scan_type) + result = try_get_aggregation_report_url_if_needed(scan_parameter, scan_client, scan_type) assert result == scan_aggregation_report_url_response.report_url From 836723df0358f5e55a6c046c7c2bd04ea9bc4531 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 11 Jun 2025 15:00:05 +0200 Subject: [PATCH 176/257] CM-48559 - Fix SAST pre-commit hook (#318) --- README.md | 3 ++ cycode/cli/apps/scan/code_scanner.py | 34 ++---------------- cycode/cli/apps/scan/commit_range_scanner.py | 9 ++++- cycode/cli/apps/scan/scan_result.py | 31 ++++++++++++++++ .../cli/printers/utils/code_snippet_syntax.py | 35 ++++++++++--------- 5 files changed, 63 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index f3e57c10..fbc6fba6 100644 --- a/README.md +++ b/README.md @@ -468,6 +468,9 @@ _How to generate a Terraform plan from Terraform configuration file?_ ### Commit History Scan +> [!NOTE] +> Secrets scanning analyzes all commits in the repository history because secrets introduced and later removed can still be leaked or exposed. SCA and SAST scanning focus only on the latest code state and the changes between branches or pull requests. Full commit history scanning is not performed for SCA and SAST. + A commit history scan is limited to a local repository’s previous commits, focused on finding any secrets within the commit history, instead of examining the repository’s current state. To execute a commit history scan, execute the following: diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index 19b43733..ad6a6e3e 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -9,6 +9,7 @@ from cycode.cli.apps.scan.scan_parameters import get_scan_parameters from cycode.cli.apps.scan.scan_result import ( create_local_scan_result, + enrich_scan_result_with_data_from_detection_rules, get_scan_result, get_sync_scan_result, print_local_scan_results, @@ -77,37 +78,6 @@ def _should_use_sync_flow(command_scan_type: str, scan_type: str, sync_option: b return True -def _enrich_scan_result_with_data_from_detection_rules( - cycode_client: 'ScanClient', scan_result: ZippedFileScanResult -) -> None: - detection_rule_ids = set() - for detections_per_file in scan_result.detections_per_file: - for detection in detections_per_file.detections: - detection_rule_ids.add(detection.detection_rule_id) - - detection_rules = cycode_client.get_detection_rules(detection_rule_ids) - detection_rules_by_id = {detection_rule.detection_rule_id: detection_rule for detection_rule in detection_rules} - - for detections_per_file in scan_result.detections_per_file: - for detection in detections_per_file.detections: - detection_rule = detection_rules_by_id.get(detection.detection_rule_id) - if not detection_rule: - # we want to make sure that BE returned it. better to not map data instead of failed scan - continue - - if not detection.severity and detection_rule.classification_data: - # it's fine to take the first one, because: - # - for "secrets" and "iac" there is only one classification rule per-detection rule - # - for "sca" and "sast" we get severity from detection service - detection.severity = detection_rule.classification_data[0].severity - - # detection_details never was typed properly. so not a problem for now - detection.detection_details['custom_remediation_guidelines'] = detection_rule.custom_remediation_guidelines - detection.detection_details['remediation_guidelines'] = detection_rule.remediation_guidelines - detection.detection_details['description'] = detection_rule.description - detection.detection_details['policy_display_name'] = detection_rule.display_name - - def _get_scan_documents_thread_func( ctx: typer.Context, is_git_diff: bool, is_commit_range: bool, scan_parameters: dict ) -> Callable[[list[Document]], tuple[str, CliError, LocalScanResult]]: @@ -140,7 +110,7 @@ def _scan_batch_thread_func(batch: list[Document]) -> tuple[str, CliError, Local should_use_sync_flow, ) - _enrich_scan_result_with_data_from_detection_rules(cycode_client, scan_result) + enrich_scan_result_with_data_from_detection_rules(cycode_client, scan_result) local_scan_result = create_local_scan_result( scan_result, batch, command_scan_type, scan_type, severity_threshold diff --git a/cycode/cli/apps/scan/commit_range_scanner.py b/cycode/cli/apps/scan/commit_range_scanner.py index b191611f..5a7893df 100644 --- a/cycode/cli/apps/scan/commit_range_scanner.py +++ b/cycode/cli/apps/scan/commit_range_scanner.py @@ -13,6 +13,7 @@ from cycode.cli.apps.scan.scan_parameters import get_scan_parameters from cycode.cli.apps.scan.scan_result import ( create_local_scan_result, + enrich_scan_result_with_data_from_detection_rules, init_default_scan_result, print_local_scan_results, ) @@ -120,12 +121,18 @@ def _scan_commit_range_documents( scan_parameters, timeout, ) + enrich_scan_result_with_data_from_detection_rules(cycode_client, scan_result) progress_bar.update(ScanProgressBarSection.SCAN) progress_bar.set_section_length(ScanProgressBarSection.GENERATE_REPORT, 1) + documents_to_scan = to_documents_to_scan + if scan_type == consts.SAST_SCAN_TYPE: + # actually for SAST from_documents_to_scan is full files and to_documents_to_scan is diff files + documents_to_scan = from_documents_to_scan + local_scan_result = create_local_scan_result( - scan_result, to_documents_to_scan, scan_command_type, scan_type, severity_threshold + scan_result, documents_to_scan, scan_command_type, scan_type, severity_threshold ) set_issue_detected_by_scan_results(ctx, [local_scan_result]) diff --git a/cycode/cli/apps/scan/scan_result.py b/cycode/cli/apps/scan/scan_result.py index 88bc6320..31a36368 100644 --- a/cycode/cli/apps/scan/scan_result.py +++ b/cycode/cli/apps/scan/scan_result.py @@ -179,3 +179,34 @@ def print_local_scan_results( printer = ctx.obj.get('console_printer') printer.update_ctx(ctx) printer.print_scan_results(local_scan_results, errors) + + +def enrich_scan_result_with_data_from_detection_rules( + cycode_client: 'ScanClient', scan_result: ZippedFileScanResult +) -> None: + detection_rule_ids = set() + for detections_per_file in scan_result.detections_per_file: + for detection in detections_per_file.detections: + detection_rule_ids.add(detection.detection_rule_id) + + detection_rules = cycode_client.get_detection_rules(detection_rule_ids) + detection_rules_by_id = {detection_rule.detection_rule_id: detection_rule for detection_rule in detection_rules} + + for detections_per_file in scan_result.detections_per_file: + for detection in detections_per_file.detections: + detection_rule = detection_rules_by_id.get(detection.detection_rule_id) + if not detection_rule: + # we want to make sure that BE returned it. better to not map data instead of failed scan + continue + + if not detection.severity and detection_rule.classification_data: + # it's fine to take the first one, because: + # - for "secrets" and "iac" there is only one classification rule per-detection rule + # - for "sca" and "sast" we get severity from detection service + detection.severity = detection_rule.classification_data[0].severity + + # detection_details never was typed properly. so not a problem for now + detection.detection_details['custom_remediation_guidelines'] = detection_rule.custom_remediation_guidelines + detection.detection_details['remediation_guidelines'] = detection_rule.remediation_guidelines + detection.detection_details['description'] = detection_rule.description + detection.detection_details['policy_display_name'] = detection_rule.display_name diff --git a/cycode/cli/printers/utils/code_snippet_syntax.py b/cycode/cli/printers/utils/code_snippet_syntax.py index d9ea3af2..20f94d4e 100644 --- a/cycode/cli/printers/utils/code_snippet_syntax.py +++ b/cycode/cli/printers/utils/code_snippet_syntax.py @@ -25,6 +25,20 @@ def get_detection_line(scan_type: str, detection: 'Detection') -> int: ) +def _get_syntax_highlighted_code(code: str, lexer: str, start_line: int, detection_line: int) -> Syntax: + return Syntax( + theme=_SYNTAX_HIGHLIGHT_THEME, + code=code, + lexer=lexer, + line_numbers=True, + word_wrap=True, + dedent=True, + tab_size=2, + start_line=start_line + 1, + highlight_lines={detection_line + 1}, + ) + + def _get_code_snippet_syntax_from_file( scan_type: str, detection: 'Detection', @@ -58,18 +72,11 @@ def _get_code_snippet_syntax_from_file( code_lines_to_render.append(line_content) code_to_render = '\n'.join(code_lines_to_render) - return Syntax( - theme=_SYNTAX_HIGHLIGHT_THEME, + return _get_syntax_highlighted_code( code=code_to_render, lexer=Syntax.guess_lexer(document.path, code=code_to_render), - line_numbers=True, - word_wrap=True, - dedent=True, - tab_size=2, - start_line=start_line_index + 1, - highlight_lines={ - detection_line + 1, - }, + start_line=start_line_index, + detection_line=detection_line, ) @@ -87,15 +94,11 @@ def _get_code_snippet_syntax_from_git_diff( violation = line_content[detection_position_in_line : detection_position_in_line + violation_length] line_content = line_content.replace(violation, obfuscate_text(violation)) - return Syntax( - theme=_SYNTAX_HIGHLIGHT_THEME, + return _get_syntax_highlighted_code( code=line_content, lexer='diff', - line_numbers=True, start_line=detection_line, - dedent=True, - tab_size=2, - highlight_lines={detection_line + 1}, + detection_line=detection_line, ) From 367f17d24547e4ce30385808acb8dba0c5864b53 Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Wed, 11 Jun 2025 15:06:15 +0100 Subject: [PATCH 177/257] CM-49357 - Improve project README readability (#319) Co-authored-by: elsapet --- README.md | 73 +++++++++++++++++++++++-------------------------------- 1 file changed, 31 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index fbc6fba6..77177820 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ This guide walks you through both installation and usage. 2. [IaC Result Example](#iac-result-example) 3. [SCA Result Example](#sca-result-example) 4. [SAST Result Example](#sast-result-example) - 4. [Company’s Custom Remediation Guidelines](#companys-custom-remediation-guidelines) + 4. [Company Custom Remediation Guidelines](#company-custom-remediation-guidelines) 3. [Ignoring Scan Results](#ignoring-scan-results) 1. [Ignoring a Secret Value](#ignoring-a-secret-value) 2. [Ignoring a Secret SHA Value](#ignoring-a-secret-sha-value) @@ -94,7 +94,7 @@ To install the Cycode CLI application on your local machine, perform the followi ./cycode ``` -3. Authenticate CLI. There are three methods to set the Cycode client ID and client secret: +3. Finally authenticate the CLI. There are three methods to set the Cycode client ID and client secret: - [cycode auth](#using-the-auth-command) (**Recommended**) - [cycode configure](#using-the-configure-command) @@ -169,7 +169,7 @@ To install the Cycode CLI application on your local machine, perform the followi `Successfully configured Cycode URLs!` -If you go into the `.cycode` folder under your user folder, you'll find these credentials were created and placed in the `credentials.yaml` file in that folder. +If you go into the `.cycode` folder under your user folder, you'll find these credentials were created and placed in the `credentials.yaml` file in that folder. The URLs were placed in the `config.yaml` file in that folder. ### Add to Environment Variables @@ -293,9 +293,9 @@ The following are the options and commands available with the Cycode CLI applica |-------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------| | [auth](#using-the-auth-command) | Authenticate your machine to associate the CLI with your Cycode account. | | [configure](#using-the-configure-command) | Initial command to configure your CLI client authentication. | -| [ignore](#ignoring-scan-results) | Ignores a specific value, path or rule ID. | +| [ignore](#ignoring-scan-results) | Ignore a specific value, path or rule ID. | | [scan](#running-a-scan) | Scan the content for Secrets/IaC/SCA/SAST violations. You`ll need to specify which scan type to perform: commit-history/path/repository/etc. | -| [report](#report-command) | Generate report. You`ll need to specify which report type to perform as SBOM. | +| [report](#report-command) | Generate report. You will need to specify which report type to perform as SBOM. | | status | Show the CLI status and exit. | # Scan Command @@ -312,9 +312,9 @@ The Cycode CLI application offers several types of scans so that you can choose | `--severity-threshold [INFO\|LOW\|MEDIUM\|HIGH\|CRITICAL]` | Show only violations at the specified level or higher. | | `--sca-scan` | Specify the SCA scan you wish to execute (`package-vulnerabilities`/`license-compliance`). The default is both. | | `--monitor` | When specified, the scan results will be recorded in Cycode. | -| `--cycode-report` | When specified, displays a link to the scan report in the Cycode platform in the console output. | -| `--no-restore` | When specified, Cycode will not run restore command. Will scan direct dependencies ONLY! | -| `--gradle-all-sub-projects` | When specified, Cycode will run gradle restore command for all sub projects. Should run from root project directory ONLY! | +| `--cycode-report` | Display a link to the scan report in the Cycode platform in the console output. | +| `--no-restore` | When specified, Cycode will not run the restore command. This will scan direct dependencies ONLY! | +| `--gradle-all-sub-projects` | Run gradle restore command for all sub projects. This should be run from the project root directory ONLY! | | `--help` | Show options for given command. | | Command | Description | @@ -328,9 +328,9 @@ The Cycode CLI application offers several types of scans so that you can choose #### Severity Option -To limit the results of the scan to a specific severity threshold, add the argument `--severity-threshold` to the scan command. +To limit the results of the scan to a specific severity threshold, the argument `--severity-threshold` can be added to the scan command. -The following command will scan the repository for policy violations that have severity of Medium or higher: +For example, the following command will scan the repository for policy violations that have severity of Medium or higher: `cycode scan --severity-threshold MEDIUM repository ~/home/git/codebase` @@ -341,13 +341,10 @@ The following command will scan the repository for policy violations that have s To push scan results tied to the [SCA policies](https://docs.cycode.com/docs/sca-policies) found in an SCA type scan to Cycode, add the argument `--monitor` to the scan command. -Consider the following example. The following command will scan the repository for SCA policy violations and push them to Cycode: +For example, the following command will scan the repository for SCA policy violations and push them to Cycode platform: `cycode scan -t sca --monitor repository ~/home/git/codebase` -When using this option, the scan results will appear in Cycode. - - #### Cycode Report Option For every scan performed using the Cycode CLI, a report is automatically generated and its results are sent to Cycode. These results are tied to the relevant policies (e.g., [SCA policies](https://docs.cycode.com/docs/sca-policies) for Repository scans) within the Cycode platform. @@ -359,7 +356,7 @@ To have the direct URL to this Cycode report printed in your CLI output after th All scan results from the CLI will appear in the CLI Logs section of Cycode. If you included the `--cycode-report` flag in your command, a direct link to the specific report will be displayed in your terminal following the scan results. > [!WARNING] -> You must be an `owner` or an `admin` in Cycode to view this page. +> You must have the `owner` or `admin` role in Cycode to view this page. ![cli-report](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/sca_report_url.png) @@ -374,7 +371,7 @@ The report page will look something like below: To scan a specific package vulnerability of your local repository, add the argument `--sca-scan package-vulnerabilities` following the `-t sca` or `--scan-type sca` option. -Consider the previous example. If you wanted to only run an SCA scan on package vulnerabilities, you could execute the following: +In the previous example, if you wanted to only run an SCA scan on package vulnerabilities, you could execute the following: `cycode scan -t sca --sca-scan package-vulnerabilities repository ~/home/git/codebase` @@ -385,7 +382,7 @@ Consider the previous example. If you wanted to only run an SCA scan on package To scan a specific branch of your local repository, add the argument `--sca-scan license-compliance` followed by the name of the branch you wish to scan. -Consider the previous example. If you wanted to only scan a branch named `dev`, you could execute the following: +In the previous example, if you wanted to only scan a branch named `dev`, you could execute the following: `cycode scan -t sca --sca-scan license-compliance repository ~/home/git/codebase -b dev` @@ -394,7 +391,7 @@ Consider the previous example. If you wanted to only scan a branch named `dev`, > [!NOTE] > This option is only available to SCA scans. -We use sbt-dependency-lock plugin to restore the lock file for SBT projects. +We use the sbt-dependency-lock plugin to restore the lock file for SBT projects. To disable lock restore in use `--no-restore` option. Prerequisites: @@ -412,7 +409,7 @@ To execute a full repository scan, execute the following: `cycode scan repository {{path}}` -For example, consider a scenario in which you want to scan your repository stored in `~/home/git/codebase`. You could then execute the following: +For example, if you wanted to scan a repository stored in `~/home/git/codebase`, you could execute the following: `cycode scan repository ~/home/git/codebase` @@ -426,7 +423,7 @@ The following option is available for use with this command: To scan a specific branch of your local repository, add the argument `-b` (alternatively, `--branch`) followed by the name of the branch you wish to scan. -Consider the previous example. If you wanted to only scan a branch named `dev`, you could execute the following: +Given the previous example, if you wanted to only scan a branch named `dev`, you could execute the following: `cycode scan repository ~/home/git/codebase -b dev` @@ -448,8 +445,8 @@ Cycode CLI supports Terraform plan scanning (supporting Terraform 0.12 and later Terraform plan file must be in JSON format (having `.json` extension) -_How to generate a Terraform plan from Terraform configuration file?_ - +If you just have a configuration file, you can generate a plan by doing the following: + 1. Initialize a working directory that contains Terraform configuration file: `terraform init` @@ -513,15 +510,13 @@ If no issues are found, the scan ends with the following success message: `Good job! No issues were found!!! 👏👏👏` -If an issue is found, a violation card appears upon completion instead. - -If an issue is found, review the file in question for the specific line highlighted by the result message. Implement any changes required to resolve the issue, then execute the scan again. +If an issue is found, a violation card appears upon completion instead. In this case you should review the file in question for the specific line highlighted by the result message. Implement any changes required to resolve the issue, then execute the scan again. ### Show/Hide Secrets -In the above example, a secret was found in the file `secret_test`, located in the subfolder `cli`. The second part of the message shows the specific line the secret appears in, which in this case is a value assigned to `googleApiKey`. +In the [examples below](#secrets-result-example), a secret was found in the file `secret_test`, located in the subfolder `cli`. The second part of the message shows the specific line the secret appears in, which in this case is a value assigned to `googleApiKey`. -Note how the above example obscures the actual secret value, replacing most of the secret with asterisks. Scans obscure secrets by default, but you may optionally disable this feature to view the full secret (assuming the machine you are viewing the scan result on is sufficiently secure from prying eyes). +Note how the example obscures the actual secret value, replacing most of the secret with asterisks. Scans obscure secrets by default, but you may optionally disable this feature to view the full secret (assuming the machine you are viewing the scan result on is sufficiently secure from prying eyes). To disable secret obfuscation, add the `--show-secret` argument to any type of scan. @@ -533,12 +528,9 @@ The result would then not be obfuscated. ### Soft Fail -Using the soft fail feature will not fail the CI/CD step within the pipeline if the Cycode scan detects an issue. -If an issue occurs during the Cycode scan, using a soft fail feature will automatically execute with success (`0`) to avoid interference. - -To configure this feature, add the `--soft-fail` option to any type of scan. This will force the scan results to succeed (exit code `0`). +In normal operation the CLI will return an exit code of `1` when issues are found in the scan results. Depending on your CI/CD setup this will usually result in an overall failure. If you don't want this to happen, you can use the soft fail feature. -Scan results are assigned with a value of exit code `1` when issues are found in the scan results; this will result in a failure within the CI/CD tool. Use the option `--soft-fail` to force the results with the exit code `0` to have no impact (i.e., to have a successful result). +By adding the `--soft-fail` option to any type of scan, the exit code will be forced to `0` regardless of whether any results are found. ### Example Scan Results @@ -633,7 +625,7 @@ Scan results are assigned with a value of exit code `1` when issues are found in ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ``` -### Company’s Custom Remediation Guidelines +### Company Custom Remediation Guidelines If your company has set custom remediation guidelines in the relevant policy via the Cycode portal, you'll see a field for “Company Guidelines” that contains the remediation guidelines you added. Note that if you haven't added any company guidelines, this field will not appear in the CLI tool. @@ -782,17 +774,14 @@ For example: `cycode ignore -g --by-value test-value`. #### Proper working directory -This is incredibly important to place the `.cycode` folder and run CLI from the same place. +It is incredibly important to place the `.cycode` folder and run CLI from the same place. You should double-check it when working with different environments like CI/CD (GitHub Actions, Jenkins, etc.). -You could commit the `.cycode` folder to the root of your repository. -In this scenario, you must run CLI scans from the repository root. -If it doesn't fit your requirements, you could temporarily copy the `.cycode` folder -wherever you want and perform a CLI scan from this folder. +You can commit the `.cycode` folder to the root of your repository. In this scenario, you must run CLI scans from the repository root. If that doesn't fit your requirements, you could temporarily copy the `.cycode` folder to wherever you want and perform a CLI scan from this folder. #### Structure ignoring rules in the config -It's important to understand how CLI stores ignore rules to be able to read these configuration files or even modify them without CLI. +It's important to understand how CLI stores ignored rules to be able to read these configuration files or even modify them without CLI. The abstract YAML structure: ```yaml @@ -807,7 +796,7 @@ Possible values of `scanTypeName`: `iac`, `sca`, `sast`, `secret`. Possible values of `ignoringType`: `paths`, `values`, `rules`, `packages`, `shas`, `cves`. -> [!WARNING] +> [!WARNING] > Values for "ignore by value" are not stored as plain text! > CLI stores sha256 hashes of the values instead. > You should put hashes of the string when modifying the configuration file by hand. @@ -844,7 +833,7 @@ The following options are available for use with this command: | Option | Description | Required | Default | |----------------------------------------------------|--------------------------------|----------|-------------------------------------------------------| -| `-f, --format [spdx-2.2\|spdx-2.3\|cyclonedx-1.4]` | SBOM format | Yes | | +| `-f, --format [spdx-2.2\|spdx-2.3\|cyclonedx-1.4]` | SBOM format | Yes | | | `-o, --output-format [JSON]` | Specify the output file format | No | json | | `--output-file PATH` | Output file | No | autogenerated filename saved to the current directory | | `--include-vulnerabilities` | Include vulnerabilities | No | False | @@ -875,7 +864,7 @@ For example:\ # Scan Logs -All CLI scan are logged in Cycode. The logs can be found under Settings > CLI Logs. +All CLI scans are logged in Cycode. The logs can be found under Settings > CLI Logs. # Syntax Help From a7523208fcc3014b934a7b9e0d963ff1ee5b0cc6 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 11 Jun 2025 17:12:40 +0200 Subject: [PATCH 178/257] CM-49113 - Add Cycode MCP (Model Context Protocol) (#316) --- .github/workflows/build_executable.yml | 4 +- README.md | 270 ++++++++++++++- cycode/cli/app.py | 7 + cycode/cli/apps/mcp/__init__.py | 14 + cycode/cli/apps/mcp/mcp_command.py | 342 +++++++++++++++++++ cycode/cli/cli_types.py | 6 + cycode/logger.py | 15 +- poetry.lock | 438 ++++++++++++++++++++++++- pyproject.toml | 4 +- 9 files changed, 1065 insertions(+), 35 deletions(-) create mode 100644 cycode/cli/apps/mcp/__init__.py create mode 100644 cycode/cli/apps/mcp/mcp_command.py diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index c9154d7d..87979685 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -15,12 +15,12 @@ jobs: strategy: fail-fast: false matrix: - os: [ ubuntu-22.04, macos-13, macos-14, windows-2019 ] + os: [ ubuntu-22.04, macos-13, macos-14, windows-2022 ] mode: [ 'onefile', 'onedir' ] exclude: - os: ubuntu-22.04 mode: onedir - - os: windows-2019 + - os: windows-2022 mode: onedir runs-on: ${{ matrix.os }} diff --git a/README.md b/README.md index 77177820..9604bd59 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,12 @@ This guide walks you through both installation and usage. 2. [On Windows](#on-windows) 2. [Install Pre-Commit Hook](#install-pre-commit-hook) 3. [Cycode CLI Commands](#cycode-cli-commands) -4. [Scan Command](#scan-command) +4. [MCP Command](#mcp-command-experiment) + 1. [Starting the MCP Server](#starting-the-mcp-server) + 2. [Available Options](#available-options) + 3. [MCP Tools](#mcp-tools) + 4. [Usage Examples](#usage-examples) +5. [Scan Command](#scan-command) 1. [Running a Scan](#running-a-scan) 1. [Options](#options) 1. [Severity Threshold](#severity-option) @@ -48,10 +53,10 @@ This guide walks you through both installation and usage. 4. [Ignoring a Secret, IaC, or SCA Rule](#ignoring-a-secret-iac-sca-or-sast-rule) 5. [Ignoring a Package](#ignoring-a-package) 6. [Ignoring via a config file](#ignoring-via-a-config-file) -5. [Report command](#report-command) +6. [Report command](#report-command) 1. [Generating SBOM Report](#generating-sbom-report) -6. [Scan logs](#scan-logs) -7. [Syntax Help](#syntax-help) +7. [Scan logs](#scan-logs) +8. [Syntax Help](#syntax-help) # Prerequisites @@ -293,29 +298,258 @@ The following are the options and commands available with the Cycode CLI applica |-------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------| | [auth](#using-the-auth-command) | Authenticate your machine to associate the CLI with your Cycode account. | | [configure](#using-the-configure-command) | Initial command to configure your CLI client authentication. | -| [ignore](#ignoring-scan-results) | Ignore a specific value, path or rule ID. | +| [ignore](#ignoring-scan-results) | Ignore a specific value, path or rule ID. | +| [mcp](#mcp-command-experiment) | Start the Model Context Protocol (MCP) server to enable AI integration with Cycode scanning capabilities. | | [scan](#running-a-scan) | Scan the content for Secrets/IaC/SCA/SAST violations. You`ll need to specify which scan type to perform: commit-history/path/repository/etc. | -| [report](#report-command) | Generate report. You will need to specify which report type to perform as SBOM. | +| [report](#report-command) | Generate report. You will need to specify which report type to perform as SBOM. | | status | Show the CLI status and exit. | +# MCP Command \[EXPERIMENT\] + +> [!WARNING] +> The MCP command is available only for Python 3.10 and above. If you're using an earlier Python version, this command will not be available. + +The Model Context Protocol (MCP) command allows you to start an MCP server that exposes Cycode's scanning capabilities to AI systems and applications. This enables AI models to interact with Cycode CLI tools via a standardized protocol. + +> [!TIP] +> For the best experience, install Cycode CLI globally on your system using `pip install cycode` or `brew install cycode`, then authenticate once with `cycode auth`. After global installation and authentication, you won't need to configure `CYCODE_CLIENT_ID` and `CYCODE_CLIENT_SECRET` environment variables in your MCP configuration files. + +[![Add MCP Server to Cursor using UV](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=cycode&config=eyJjb21tYW5kIjoidXZ4IGN5Y29kZSBtY3AiLCJlbnYiOnsiQ1lDT0RFX0NMSUVOVF9JRCI6InlvdXItY3ljb2RlLWlkIiwiQ1lDT0RFX0NMSUVOVF9TRUNSRVQiOiJ5b3VyLWN5Y29kZS1zZWNyZXQta2V5IiwiQ1lDT0RFX0FQSV9VUkwiOiJodHRwczovL2FwaS5jeWNvZGUuY29tIiwiQ1lDT0RFX0FQUF9VUkwiOiJodHRwczovL2FwcC5jeWNvZGUuY29tIn19) + + +## Starting the MCP Server + +To start the MCP server, use the following command: + +```bash +cycode mcp +``` + +By default, this starts the server using the `stdio` transport, which is suitable for local integrations and AI applications that can spawn subprocesses. + +### Available Options + +| Option | Description | +|-------------------|--------------------------------------------------------------------------------------------| +| `-t, --transport` | Transport type for the MCP server: `stdio`, `sse`, or `streamable-http` (default: `stdio`) | +| `-H, --host` | Host address to bind the server (used only for non stdio transport) (default: `127.0.0.1`) | +| `-p, --port` | Port number to bind the server (used only for non stdio transport) (default: `8000`) | +| `--help` | Show help message and available options | + +### MCP Tools + +The MCP server provides the following tools that AI systems can use: + +| Tool Name | Description | +|----------------------|---------------------------------------------------------------------------------------------| +| `cycode_secret_scan` | Scan files for hardcoded secrets | +| `cycode_sca_scan` | Scan files for Software Composition Analysis (SCA) - vulnerabilities and license issues | +| `cycode_iac_scan` | Scan files for Infrastructure as Code (IaC) misconfigurations | +| `cycode_sast_scan` | Scan files for Static Application Security Testing (SAST) - code quality and security flaws | +| `cycode_status` | Get Cycode CLI version, authentication status, and configuration information | + +### Usage Examples + +#### Basic Command Examples + +Start the MCP server with default settings (stdio transport): +```bash +cycode mcp +``` + +Start the MCP server with explicit stdio transport: +```bash +cycode mcp -t stdio +``` + +Start the MCP server with Server-Sent Events (SSE) transport: +```bash +cycode mcp -t sse -p 8080 +``` + +Start the MCP server with streamable HTTP transport on custom host and port: +```bash +cycode mcp -t streamable-http -H 0.0.0.0 -p 9000 +``` + +Learn more about MCP Transport types in the [MCP Protocol Specification – Transports](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports). + +#### Configuration Examples + +##### Using MCP with Cursor/VS Code/Claude Desktop/etc (mcp.json) + +> [!NOTE] +> For EU Cycode environments, make sure to set the appropriate `CYCODE_API_URL` and `CYCODE_APP_URL` values in the environment variables (e.g., `https://api.eu.cycode.com` and `https://app.eu.cycode.com`). + +Follow [this guide](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) to configure the MCP server in your **VS Code/GitHub Copilot**. Keep in mind that in `settings.json`, there is an `mcp` object containing a nested `servers` sub-object, rather than a standalone `mcpServers` object. + +For **stdio transport** (direct execution): +```json +{ + "mcpServers": { + "cycode": { + "command": "cycode", + "args": ["mcp"], + "env": { + "CYCODE_CLIENT_ID": "your-cycode-id", + "CYCODE_CLIENT_SECRET": "your-cycode-secret-key", + "CYCODE_API_URL": "https://api.cycode.com", + "CYCODE_APP_URL": "https://app.cycode.com" + } + } + } +} +``` + +For **stdio transport** with `pipx` installation: +```json +{ + "mcpServers": { + "cycode": { + "command": "pipx", + "args": ["run", "cycode", "mcp"], + "env": { + "CYCODE_CLIENT_ID": "your-cycode-id", + "CYCODE_CLIENT_SECRET": "your-cycode-secret-key", + "CYCODE_API_URL": "https://api.cycode.com", + "CYCODE_APP_URL": "https://app.cycode.com" + } + } + } +} +``` + +For **stdio transport** with `uvx` installation: +```json +{ + "mcpServers": { + "cycode": { + "command": "uvx", + "args": ["cycode", "mcp"], + "env": { + "CYCODE_CLIENT_ID": "your-cycode-id", + "CYCODE_CLIENT_SECRET": "your-cycode-secret-key", + "CYCODE_API_URL": "https://api.cycode.com", + "CYCODE_APP_URL": "https://app.cycode.com" + } + } + } +} +``` + +For **SSE transport** (Server-Sent Events): +```json +{ + "mcpServers": { + "cycode": { + "url": "http://127.0.0.1:8000/sse" + } + } +} +``` + +For **SSE transport** on custom port: +```json +{ + "mcpServers": { + "cycode": { + "url": "http://127.0.0.1:8080/sse" + } + } +} +``` + +For **streamable HTTP transport**: +```json +{ + "mcpServers": { + "cycode": { + "url": "http://127.0.0.1:8000/mcp" + } + } +} +``` + +##### Running MCP Server in Background + +For **SSE transport** (start server first, then configure client): +```bash +# Start the MCP server in the background +cycode mcp -t sse -p 8000 & + +# Configure in mcp.json +{ + "mcpServers": { + "cycode": { + "url": "http://127.0.0.1:8000/sse" + } + } +} +``` + +For **streamable HTTP transport**: +```bash +# Start the MCP server in the background +cycode mcp -t streamable-http -H 127.0.0.2 -p 9000 & + +# Configure in mcp.json +{ + "mcpServers": { + "cycode": { + "url": "http://127.0.0.2:9000/mcp" + } + } +} +``` + +> [!NOTE] +> The MCP server requires proper Cycode CLI authentication to function. Make sure you have authenticated using `cycode auth` or configured your credentials before starting the MCP server. + +### Troubleshooting MCP + +If you encounter issues with the MCP server, you can enable debug logging to get more detailed information about what's happening. There are two ways to enable debug logging: + +1. Using the `-v` or `--verbose` flag: +```bash +cycode -v mcp +``` + +2. Using the `CYCODE_CLI_VERBOSE` environment variable: +```bash +CYCODE_CLI_VERBOSE=1 cycode mcp +``` + +The debug logs will show detailed information about: +- Server startup and configuration +- Connection attempts and status +- Tool execution and results +- Any errors or warnings that occur + +This information can be helpful when: +- Diagnosing connection issues +- Understanding why certain tools aren't working +- Identifying authentication problems +- Debugging transport-specific issues + + # Scan Command ## Running a Scan The Cycode CLI application offers several types of scans so that you can choose the option that best fits your case. The following are the current options and commands available: -| Option | Description | -|------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------| -| `-t, --scan-type [secret\|iac\|sca\|sast]` | Specify the scan you wish to execute (`secret`/`iac`/`sca`/`sast`), the default is `secret`. | -| `--show-secret BOOLEAN` | Show secrets in plain text. See [Show/Hide Secrets](#showhide-secrets) section for more details. | -| `--soft-fail BOOLEAN` | Run scan without failing, always return a non-error status code. See [Soft Fail](#soft-fail) section for more details. | -| `--severity-threshold [INFO\|LOW\|MEDIUM\|HIGH\|CRITICAL]` | Show only violations at the specified level or higher. | -| `--sca-scan` | Specify the SCA scan you wish to execute (`package-vulnerabilities`/`license-compliance`). The default is both. | -| `--monitor` | When specified, the scan results will be recorded in Cycode. | -| `--cycode-report` | Display a link to the scan report in the Cycode platform in the console output. | -| `--no-restore` | When specified, Cycode will not run the restore command. This will scan direct dependencies ONLY! | -| `--gradle-all-sub-projects` | Run gradle restore command for all sub projects. This should be run from the project root directory ONLY! | -| `--help` | Show options for given command. | +| Option | Description | +|------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------| +| `-t, --scan-type [secret\|iac\|sca\|sast]` | Specify the scan you wish to execute (`secret`/`iac`/`sca`/`sast`), the default is `secret`. | +| `--show-secret BOOLEAN` | Show secrets in plain text. See [Show/Hide Secrets](#showhide-secrets) section for more details. | +| `--soft-fail BOOLEAN` | Run scan without failing, always return a non-error status code. See [Soft Fail](#soft-fail) section for more details. | +| `--severity-threshold [INFO\|LOW\|MEDIUM\|HIGH\|CRITICAL]` | Show only violations at the specified level or higher. | +| `--sca-scan` | Specify the SCA scan you wish to execute (`package-vulnerabilities`/`license-compliance`). The default is both. | +| `--monitor` | When specified, the scan results will be recorded in Cycode. | +| `--cycode-report` | Display a link to the scan report in the Cycode platform in the console output. | +| `--no-restore` | When specified, Cycode will not run the restore command. This will scan direct dependencies ONLY! | +| `--gradle-all-sub-projects` | Run gradle restore command for all sub projects. This should be run from the project root directory ONLY! | +| `--help` | Show options for given command. | | Command | Description | |----------------------------------------|-----------------------------------------------------------------| diff --git a/cycode/cli/app.py b/cycode/cli/app.py index 2ae004a6..1b13ebf2 100644 --- a/cycode/cli/app.py +++ b/cycode/cli/app.py @@ -1,4 +1,5 @@ import logging +import sys from typing import Annotated, Optional import typer @@ -9,6 +10,10 @@ from cycode import __version__ from cycode.cli.apps import ai_remediation, auth, configure, ignore, report, scan, status + +if sys.version_info >= (3, 10): + from cycode.cli.apps import mcp + from cycode.cli.cli_types import OutputTypeOption from cycode.cli.consts import CLI_CONTEXT_SETTINGS from cycode.cli.printers import ConsolePrinter @@ -47,6 +52,8 @@ app.add_typer(report.app) app.add_typer(scan.app) app.add_typer(status.app) +if sys.version_info >= (3, 10): + app.add_typer(mcp.app) def check_latest_version_on_close(ctx: typer.Context) -> None: diff --git a/cycode/cli/apps/mcp/__init__.py b/cycode/cli/apps/mcp/__init__.py new file mode 100644 index 00000000..fd328845 --- /dev/null +++ b/cycode/cli/apps/mcp/__init__.py @@ -0,0 +1,14 @@ +import typer + +from cycode.cli.apps.mcp.mcp_command import mcp_command + +app = typer.Typer() + +_mcp_command_docs = 'https://github.com/cycodehq/cycode-cli/blob/main/README.md#mcp-command-experiment' +_mcp_command_epilog = f'[bold]Documentation:[/] [link={_mcp_command_docs}]{_mcp_command_docs}[/link]' + +app.command( + name='mcp', + short_help='[EXPERIMENT] Start the Cycode MCP (Model Context Protocol) server.', + epilog=_mcp_command_epilog, +)(mcp_command) diff --git a/cycode/cli/apps/mcp/mcp_command.py b/cycode/cli/apps/mcp/mcp_command.py new file mode 100644 index 00000000..0dcef968 --- /dev/null +++ b/cycode/cli/apps/mcp/mcp_command.py @@ -0,0 +1,342 @@ +import asyncio +import json +import logging +import os +import sys +import tempfile +import uuid +from pathlib import Path +from typing import Annotated, Any + +import typer +from pydantic import Field + +from cycode.cli.cli_types import McpTransportOption, ScanTypeOption +from cycode.cli.utils.sentry import add_breadcrumb +from cycode.logger import LoggersManager, get_logger + +try: + from mcp.server.fastmcp import FastMCP + from mcp.server.fastmcp.tools import Tool +except ImportError: + raise ImportError( + 'Cycode MCP is not supported for your Python version. MCP support requires Python 3.10 or higher.' + ) from None + + +_logger = get_logger('Cycode MCP') + +_DEFAULT_RUN_COMMAND_TIMEOUT = 5 * 60 + +_FILES_TOOL_FIELD = Field(description='Files to scan, mapping file paths to their content') + + +def _is_debug_mode() -> bool: + return LoggersManager.global_logging_level == logging.DEBUG + + +def _gen_random_id() -> str: + return uuid.uuid4().hex + + +def _get_current_executable() -> str: + """Get the current executable path for spawning subprocess.""" + if getattr(sys, 'frozen', False): # pyinstaller bundle + return sys.executable + + return 'cycode' + + +async def _run_cycode_command(*args: str, timeout: int = _DEFAULT_RUN_COMMAND_TIMEOUT) -> dict[str, Any]: + """Run a cycode command asynchronously and return the parsed result. + + Args: + *args: Command arguments to append after 'cycode -o json' + timeout: Timeout in seconds (default 5 minutes) + + Returns: + Dictionary containing the parsed JSON result or error information + """ + verbose = ['-v'] if _is_debug_mode() else [] + cmd_args = [_get_current_executable(), *verbose, '-o', 'json', *list(args)] + _logger.debug('Running Cycode CLI command: %s', ' '.join(cmd_args)) + + try: + process = await asyncio.create_subprocess_exec( + *cmd_args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout) + stdout_str = stdout.decode('UTF-8', errors='replace') if stdout else '' + stderr_str = stderr.decode('UTF-8', errors='replace') if stderr else '' + + if _is_debug_mode(): # redirect debug output + sys.stderr.write(stderr_str) + + if not stdout_str: + return {'error': 'No output from command', 'stderr': stderr_str, 'returncode': process.returncode} + + try: + return json.loads(stdout_str) + except json.JSONDecodeError: + return { + 'error': 'Failed to parse JSON output', + 'stdout': stdout_str, + 'stderr': stderr_str, + 'returncode': process.returncode, + } + except asyncio.TimeoutError: + return {'error': f'Command timeout after {timeout} seconds'} + except Exception as e: + return {'error': f'Failed to run command: {e!s}'} + + +def _create_temp_files(files_content: dict[str, str]) -> list[str]: + """Create temporary files from content and return their paths.""" + temp_dir = tempfile.mkdtemp(prefix='cycode_mcp_') + temp_files = [] + + _logger.debug('Creating temporary files in directory: %s', temp_dir) + + for file_path, content in files_content.items(): + safe_filename = f'{_gen_random_id()}_{Path(file_path).name}' + temp_file_path = os.path.join(temp_dir, safe_filename) + + os.makedirs(os.path.dirname(temp_file_path), exist_ok=True) + + _logger.debug('Creating temp file: %s', temp_file_path) + with open(temp_file_path, 'w', encoding='UTF-8') as f: + f.write(content) + + temp_files.append(temp_file_path) + + return temp_files + + +def _cleanup_temp_files(temp_files: list[str]) -> None: + """Clean up temporary files and directories.""" + + temp_dirs = set() + for temp_file in temp_files: + try: + if os.path.exists(temp_file): + _logger.debug('Removing temp file: %s', temp_file) + os.remove(temp_file) + temp_dirs.add(os.path.dirname(temp_file)) + except OSError as e: + _logger.warning('Failed to remove temp file %s: %s', temp_file, e) + + for temp_dir in temp_dirs: + try: + if os.path.exists(temp_dir) and not os.listdir(temp_dir): + _logger.debug('Removing temp directory: %s', temp_dir) + os.rmdir(temp_dir) + except OSError as e: + _logger.warning('Failed to remove temp directory %s: %s', temp_dir, e) + + +async def _run_cycode_scan(scan_type: ScanTypeOption, temp_files: list[str]) -> dict[str, Any]: + """Run cycode scan command and return the result.""" + return await _run_cycode_command(*['scan', '-t', str(scan_type), 'path', *temp_files]) + + +async def _run_cycode_status() -> dict[str, Any]: + """Run cycode status command and return the result.""" + return await _run_cycode_command('status') + + +async def _cycode_scan_tool(scan_type: ScanTypeOption, files: dict[str, str] = _FILES_TOOL_FIELD) -> str: + _tool_call_id = _gen_random_id() + _logger.info('Scan tool called, %s', {'scan_type': scan_type, 'call_id': _tool_call_id}) + + if not files: + _logger.error('No files provided for scan') + return json.dumps({'error': 'No files provided'}) + + temp_files = _create_temp_files(files) + + try: + _logger.info( + 'Running Cycode scan, %s', + {'scan_type': scan_type, 'files_count': len(temp_files), 'call_id': _tool_call_id}, + ) + result = await _run_cycode_scan(scan_type, temp_files) + _logger.info('Scan completed, %s', {'scan_type': scan_type, 'call_id': _tool_call_id}) + return json.dumps(result, indent=2) + except Exception as e: + _logger.error('Scan failed, %s', {'scan_type': scan_type, 'call_id': _tool_call_id, 'error': str(e)}) + return json.dumps({'error': f'Scan failed: {e!s}'}, indent=2) + finally: + _cleanup_temp_files(temp_files) + + +async def cycode_secret_scan(files: dict[str, str] = _FILES_TOOL_FIELD) -> str: + """Scan files for hardcoded secrets. + + Use this tool when you need to: + - scan code for hardcoded secrets, API keys, passwords, tokens + - verify that code doesn't contain exposed credentials + - detect potential security vulnerabilities from secret exposure + + Args: + files: Dictionary mapping file paths to their content + + Returns: + JSON string containing scan results and any secrets found + """ + return await _cycode_scan_tool(ScanTypeOption.SECRET, files) + + +async def cycode_sca_scan(files: dict[str, str] = _FILES_TOOL_FIELD) -> str: + """Scan files for Software Composition Analysis (SCA) - vulnerabilities and license issues. + + Use this tool when you need to: + - scan dependencies for known security vulnerabilities + - check for license compliance issues + - analyze third-party component risks + - verify software supply chain security + - review package.json, requirements.txt, pom.xml and other dependency files + + Args: + files: Dictionary mapping file paths to their content + + Returns: + JSON string containing scan results, vulnerabilities, and license issues found + """ + return await _cycode_scan_tool(ScanTypeOption.SCA, files) + + +async def cycode_iac_scan(files: dict[str, str] = _FILES_TOOL_FIELD) -> str: + """Scan files for Infrastructure as Code (IaC) misconfigurations. + + Use this tool when you need to: + - scan Terraform, CloudFormation, Kubernetes YAML files + - check for cloud security misconfigurations + - verify infrastructure compliance and best practices + - detect potential security issues in infrastructure definitions + - review Docker files for security issues + + Args: + files: Dictionary mapping file paths to their content + + Returns: + JSON string containing scan results and any misconfigurations found + """ + return await _cycode_scan_tool(ScanTypeOption.IAC, files) + + +async def cycode_sast_scan(files: dict[str, str] = _FILES_TOOL_FIELD) -> str: + """Scan files for Static Application Security Testing (SAST) - code quality and security flaws. + + Use this tool when you need to: + - scan source code for security vulnerabilities + - detect code quality issues and potential bugs + - check for insecure coding practices + - verify code follows security best practices + - find SQL injection, XSS, and other application security issues + + Args: + files: Dictionary mapping file paths to their content + + Returns: + JSON string containing scan results and any security flaws found + """ + return await _cycode_scan_tool(ScanTypeOption.SAST, files) + + +async def cycode_status() -> str: + """Get Cycode CLI version, authentication status, and configuration information. + + Use this tool when you need to: + - verify Cycode CLI is properly configured + - check authentication status + - get CLI version information + - troubleshoot setup issues + - confirm service connectivity + + Returns: + JSON string containing CLI status, version, and configuration details + """ + _tool_call_id = _gen_random_id() + _logger.info('Status tool called') + + try: + _logger.info('Running Cycode status check, %s', {'call_id': _tool_call_id}) + result = await _run_cycode_status() + _logger.info('Status check completed, %s', {'call_id': _tool_call_id}) + + return json.dumps(result, indent=2) + except Exception as e: + _logger.error('Status check failed, %s', {'call_id': _tool_call_id, 'error': str(e)}) + return json.dumps({'error': f'Status check failed: {e!s}'}, indent=2) + + +def _create_mcp_server(host: str, port: int) -> FastMCP: + """Create and configure the MCP server.""" + tools = [ + Tool.from_function(cycode_status), + Tool.from_function(cycode_secret_scan), + Tool.from_function(cycode_sca_scan), + Tool.from_function(cycode_iac_scan), + Tool.from_function(cycode_sast_scan), + ] + _logger.info('Creating MCP server with tools: %s', [tool.name for tool in tools]) + return FastMCP( + 'cycode', + tools=tools, + host=host, + port=port, + debug=_is_debug_mode(), + log_level='DEBUG' if _is_debug_mode() else 'INFO', + ) + + +def _run_mcp_server(transport: McpTransportOption, host: str, port: int) -> None: + """Run the MCP server using transport.""" + mcp = _create_mcp_server(host, port) + mcp.run(transport=str(transport)) # type: ignore[arg-type] + + +def mcp_command( + transport: Annotated[ + McpTransportOption, + typer.Option( + '--transport', + '-t', + case_sensitive=False, + help='Transport type for the MCP server.', + ), + ] = McpTransportOption.STDIO, + host: str = typer.Option( + '127.0.0.1', + '--host', + '-H', + help='Host address to bind the server (used only for non stdio transport).', + ), + port: int = typer.Option( + 8000, + '--port', + '-p', + help='Port number to bind the server (used only for non stdio transport).', + ), +) -> None: + """:robot: Start the Cycode MCP (Model Context Protocol) server. + + The MCP server provides tools for scanning code with Cycode CLI: + - cycode_secret_scan: Scan for hardcoded secrets + - cycode_sca_scan: Software Composition Analysis scanning + - cycode_iac_scan: Infrastructure as Code scanning + - cycode_sast_scan: Static Application Security Testing scanning + - cycode_status: Get Cycode CLI status (version, auth status) and configuration + + Examples: + cycode mcp # Start with default transport (stdio) + cycode mcp -t sse -p 8080 # Start with Server-Sent Events (SSE) transport on port 8080 + """ + add_breadcrumb('mcp') + + try: + _run_mcp_server(transport, host, port) + except Exception as e: + _logger.error('MCP server error', exc_info=e) + raise typer.Exit(1) from e diff --git a/cycode/cli/cli_types.py b/cycode/cli/cli_types.py index c2fa12a2..a5d7f9d9 100644 --- a/cycode/cli/cli_types.py +++ b/cycode/cli/cli_types.py @@ -8,6 +8,12 @@ def __str__(self) -> str: return self.value +class McpTransportOption(StrEnum): + STDIO = 'stdio' + SSE = 'sse' + STREAMABLE_HTTP = 'streamable-http' + + class OutputTypeOption(StrEnum): RICH = 'rich' TEXT = 'text' diff --git a/cycode/logger.py b/cycode/logger.py index 0ec6023f..2fd44e4f 100644 --- a/cycode/logger.py +++ b/cycode/logger.py @@ -1,6 +1,6 @@ import logging import sys -from typing import NamedTuple, Optional, Union +from typing import ClassVar, NamedTuple, Optional, Union import click import typer @@ -42,10 +42,15 @@ class CreatedLogger(NamedTuple): control_level_in_runtime: bool -_CREATED_LOGGERS: set[CreatedLogger] = set() +class LoggersManager: + loggers: ClassVar[set[CreatedLogger]] = set() + global_logging_level: Optional[int] = None def get_logger_level() -> Optional[Union[int, str]]: + if LoggersManager.global_logging_level is not None: + return LoggersManager.global_logging_level + config_level = get_val_as_string(consts.LOGGING_LEVEL_ENV_VAR_NAME) return logging.getLevelName(config_level) @@ -54,12 +59,14 @@ def get_logger(logger_name: Optional[str] = None, control_level_in_runtime: bool new_logger = logging.getLogger(logger_name) new_logger.setLevel(get_logger_level()) - _CREATED_LOGGERS.add(CreatedLogger(logger=new_logger, control_level_in_runtime=control_level_in_runtime)) + LoggersManager.loggers.add(CreatedLogger(logger=new_logger, control_level_in_runtime=control_level_in_runtime)) return new_logger def set_logging_level(level: int) -> None: - for created_logger in _CREATED_LOGGERS: + LoggersManager.global_logging_level = level + + for created_logger in LoggersManager.loggers: if created_logger.control_level_in_runtime: created_logger.logger.setLevel(level) diff --git a/poetry.lock b/poetry.lock index 65e6a971..cdaff5cf 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. [[package]] name = "altgraph" @@ -13,6 +13,42 @@ files = [ {file = "altgraph-0.17.4.tar.gz", hash = "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406"}, ] +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "anyio" +version = "4.9.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.10\"" +files = [ + {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, + {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] +test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] +trio = ["trio (>=0.26.1)"] + [[package]] name = "arrow" version = "1.3.0" @@ -295,12 +331,12 @@ version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["test"] -markers = "python_version < \"3.11\"" +groups = ["main", "test"] files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, ] +markers = {main = "python_version == \"3.10\"", test = "python_version < \"3.11\""} [package.extras] test = ["pytest (>=6)"] @@ -339,6 +375,81 @@ gitdb = ">=4.0.1,<5" doc = ["sphinx (>=7.1.2,<7.2)", "sphinx-autodoc-typehints", "sphinx_rtd_theme"] test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock ; python_version < \"3.8\"", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions ; python_version < \"3.11\""] +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "python_version >= \"3.10\"" +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "python_version >= \"3.10\"" +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "python_version >= \"3.10\"" +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "httpx-sse" +version = "0.4.0" +description = "Consume Server-Sent Event (SSE) messages with HTTPX." +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "python_version >= \"3.10\"" +files = [ + {file = "httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721"}, + {file = "httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f"}, +] + [[package]] name = "idna" version = "3.10" @@ -361,7 +472,7 @@ description = "Read metadata from Python packages" optional = false python-versions = ">=3.9" groups = ["executable"] -markers = "python_version < \"3.10\"" +markers = "python_version == \"3.9\"" files = [ {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, @@ -452,6 +563,35 @@ dev = ["marshmallow[tests]", "pre-commit (>=3.5,<4.0)", "tox"] docs = ["alabaster (==1.0.0)", "autodocsumm (==0.2.13)", "sphinx (==8.0.2)", "sphinx-issues (==4.1.0)", "sphinx-version-warning (==1.1.2)"] tests = ["pytest", "pytz", "simplejson"] +[[package]] +name = "mcp" +version = "1.9.3" +description = "Model Context Protocol SDK" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "python_version >= \"3.10\"" +files = [ + {file = "mcp-1.9.3-py3-none-any.whl", hash = "sha256:69b0136d1ac9927402ed4cf221d4b8ff875e7132b0b06edd446448766f34f9b9"}, + {file = "mcp-1.9.3.tar.gz", hash = "sha256:587ba38448e81885e5d1b84055cfcc0ca56d35cd0c58f50941cab01109405388"}, +] + +[package.dependencies] +anyio = ">=4.5" +httpx = ">=0.27" +httpx-sse = ">=0.4" +pydantic = ">=2.7.2,<3.0.0" +pydantic-settings = ">=2.5.2" +python-multipart = ">=0.0.9" +sse-starlette = ">=1.6.1" +starlette = ">=0.27" +uvicorn = {version = ">=0.23.1", markers = "sys_platform != \"emscripten\""} + +[package.extras] +cli = ["python-dotenv (>=1.0.0)", "typer (>=0.12.4)"] +rich = ["rich (>=13.9.4)"] +ws = ["websockets (>=15.0.1)"] + [[package]] name = "mdurl" version = "0.1.2" @@ -533,6 +673,165 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "pydantic" +version = "2.11.5" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic-2.11.5-py3-none-any.whl", hash = "sha256:f9c26ba06f9747749ca1e5c94d6a85cb84254577553c8785576fd38fa64dc0f7"}, + {file = "pydantic-2.11.5.tar.gz", hash = "sha256:7f853db3d0ce78ce8bbb148c401c2cdd6431b3473c0cdff2755c7690952a7b7a"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.33.2" +typing-extensions = ">=4.12.2" +typing-inspection = ">=0.4.0" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, + {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, + {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, + {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, + {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, + {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, + {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, + {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, + {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, + {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, + {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, + {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, + {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pydantic-settings" +version = "2.9.1" +description = "Settings management using Pydantic" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.10\"" +files = [ + {file = "pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef"}, + {file = "pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268"}, +] + +[package.dependencies] +pydantic = ">=2.7.0" +python-dotenv = ">=0.21.0" +typing-inspection = ">=0.4.0" + +[package.extras] +aws-secrets-manager = ["boto3 (>=1.35.0)", "boto3-stubs[secretsmanager]"] +azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] +gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] +toml = ["tomli (>=2.0.1)"] +yaml = ["pyyaml (>=6.0.1)"] + [[package]] name = "pyfakefs" version = "5.7.4" @@ -687,6 +986,35 @@ files = [ [package.dependencies] six = ">=1.5" +[[package]] +name = "python-dotenv" +version = "1.1.0" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.10\"" +files = [ + {file = "python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d"}, + {file = "python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "python-multipart" +version = "0.0.20" +description = "A streaming multipart parser for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "python_version >= \"3.10\"" +files = [ + {file = "python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104"}, + {file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"}, +] + [[package]] name = "pywin32-ctypes" version = "0.2.3" @@ -765,19 +1093,19 @@ files = [ [[package]] name = "requests" -version = "2.32.3" +version = "2.32.4" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" groups = ["main", "test"] files = [ - {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, - {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, + {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, + {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, ] [package.dependencies] certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" +charset_normalizer = ">=2,<4" idna = ">=2.5,<4" urllib3 = ">=1.21.1,<3" @@ -969,6 +1297,60 @@ files = [ {file = "smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5"}, ] +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +groups = ["main"] +markers = "python_version >= \"3.10\"" +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "sse-starlette" +version = "2.3.6" +description = "SSE plugin for Starlette" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.10\"" +files = [ + {file = "sse_starlette-2.3.6-py3-none-any.whl", hash = "sha256:d49a8285b182f6e2228e2609c350398b2ca2c36216c2675d875f81e93548f760"}, + {file = "sse_starlette-2.3.6.tar.gz", hash = "sha256:0382336f7d4ec30160cf9ca0518962905e1b69b72d6c1c995131e0a703b436e3"}, +] + +[package.dependencies] +anyio = ">=4.7.0" + +[package.extras] +daphne = ["daphne (>=4.2.0)"] +examples = ["aiosqlite (>=0.21.0)", "fastapi (>=0.115.12)", "sqlalchemy[asyncio,examples] (>=2.0.41)", "starlette (>=0.41.3)", "uvicorn (>=0.34.0)"] +granian = ["granian (>=2.3.1)"] +uvicorn = ["uvicorn (>=0.34.0)"] + +[[package]] +name = "starlette" +version = "0.47.0" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.10\"" +files = [ + {file = "starlette-0.47.0-py3-none-any.whl", hash = "sha256:9d052d4933683af40ffd47c7465433570b4949dc937e20ad1d73b34e72f10c37"}, + {file = "starlette-0.47.0.tar.gz", hash = "sha256:1f64887e94a447fed5f23309fb6890ef23349b7e478faa7b24a851cd4eb844af"}, +] + +[package.dependencies] +anyio = ">=3.6.2,<5" + +[package.extras] +full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] + [[package]] name = "tenacity" version = "9.0.0" @@ -1082,6 +1464,21 @@ files = [ {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, ] +[[package]] +name = "typing-inspection" +version = "0.4.1" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, + {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + [[package]] name = "urllib3" version = "1.26.19" @@ -1099,6 +1496,27 @@ brotli = ["brotli (==1.0.9) ; os_name != \"nt\" and python_version < \"3\" and p secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress ; python_version == \"2.7\"", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] +[[package]] +name = "uvicorn" +version = "0.34.3" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.10\" and sys_platform != \"emscripten\"" +files = [ + {file = "uvicorn-0.34.3-py3-none-any.whl", hash = "sha256:16246631db62bdfbf069b0645177d6e8a77ba950cfedbfd093acef9444e4d885"}, + {file = "uvicorn-0.34.3.tar.gz", hash = "sha256:35919a9a979d7a59334b6b10e05d77c1d0d574c50e0fc98b8b1a0f165708b55a"}, +] + +[package.dependencies] +click = ">=7.0" +h11 = ">=0.8" +typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} + +[package.extras] +standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] + [[package]] name = "zipp" version = "3.21.0" @@ -1106,7 +1524,7 @@ description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.9" groups = ["executable"] -markers = "python_version < \"3.10\"" +markers = "python_version == \"3.9\"" files = [ {file = "zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931"}, {file = "zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4"}, @@ -1123,4 +1541,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9,<3.14" -content-hash = "14f258101aa534aadfc871aa5082ad773aa99873587c21c0598567435bfa5d9a" +content-hash = "2a401929c8b999931e32f020bd794e9dc3716647bacc79afa70b7791ab86ce00" diff --git a/pyproject.toml b/pyproject.toml index 755d8207..022d8206 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ marshmallow = ">=3.15.0,<3.23.0" # 3.23 dropped support for Python 3.8 gitpython = ">=3.1.30,<3.2.0" arrow = ">=1.0.0,<1.4.0" binaryornot = ">=0.4.4,<0.5.0" -requests = ">=2.32.2,<3.0" +requests = ">=2.32.4,<3.0" urllib3 = "1.26.19" # 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" @@ -42,6 +42,8 @@ rich = ">=13.9.4, <14" patch-ng = "1.18.1" typer = "^0.15.3" tenacity = ">=9.0.0,<9.1.0" +mcp = { version = ">=1.9.3,<2.0.0", markers = "python_version >= '3.10'" } +pydantic = ">=2.11.5,<3.0.0" [tool.poetry.group.test.dependencies] mock = ">=4.0.3,<4.1.0" From e7e50d3afc4c40d1618d4118e0c1176adfdbbe2b Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 18 Jun 2025 14:59:03 +0200 Subject: [PATCH 179/257] CM-49647 - Fix MCP file handling (#320) --- cycode/cli/apps/mcp/mcp_command.py | 140 +++++++++++++++++++---------- poetry.lock | 19 +++- pyproject.toml | 1 + 3 files changed, 113 insertions(+), 47 deletions(-) diff --git a/cycode/cli/apps/mcp/mcp_command.py b/cycode/cli/apps/mcp/mcp_command.py index 0dcef968..b9989ce2 100644 --- a/cycode/cli/apps/mcp/mcp_command.py +++ b/cycode/cli/apps/mcp/mcp_command.py @@ -2,13 +2,14 @@ import json import logging import os +import shutil import sys import tempfile import uuid -from pathlib import Path from typing import Annotated, Any import typer +from pathvalidate import sanitize_filepath from pydantic import Field from cycode.cli.cli_types import McpTransportOption, ScanTypeOption @@ -26,7 +27,7 @@ _logger = get_logger('Cycode MCP') -_DEFAULT_RUN_COMMAND_TIMEOUT = 5 * 60 +_DEFAULT_RUN_COMMAND_TIMEOUT = 10 * 60 _FILES_TOOL_FIELD = Field(description='Files to scan, mapping file paths to their content') @@ -91,48 +92,76 @@ async def _run_cycode_command(*args: str, timeout: int = _DEFAULT_RUN_COMMAND_TI return {'error': f'Failed to run command: {e!s}'} -def _create_temp_files(files_content: dict[str, str]) -> list[str]: - """Create temporary files from content and return their paths.""" - temp_dir = tempfile.mkdtemp(prefix='cycode_mcp_') - temp_files = [] +def _sanitize_file_path(file_path: str) -> str: + """Sanitize file path to prevent path traversal and other security issues. - _logger.debug('Creating temporary files in directory: %s', temp_dir) + Args: + file_path: The file path to sanitize - for file_path, content in files_content.items(): - safe_filename = f'{_gen_random_id()}_{Path(file_path).name}' - temp_file_path = os.path.join(temp_dir, safe_filename) + Returns: + Sanitized file path safe for use in temporary directory - os.makedirs(os.path.dirname(temp_file_path), exist_ok=True) + Raises: + ValueError: If the path is invalid or potentially dangerous + """ + if not file_path or not isinstance(file_path, str): + raise ValueError('File path must be a non-empty string') - _logger.debug('Creating temp file: %s', temp_file_path) - with open(temp_file_path, 'w', encoding='UTF-8') as f: - f.write(content) + return sanitize_filepath(file_path, platform='auto', validate_after_sanitize=True) - temp_files.append(temp_file_path) - return temp_files +class _TempFilesManager: + """Context manager for creating and cleaning up temporary files. + Creates a temporary directory structure that preserves original file paths + inside a call_id as a suffix. Automatically cleans up all files and directories + when exiting the context. + """ -def _cleanup_temp_files(temp_files: list[str]) -> None: - """Clean up temporary files and directories.""" + def __init__(self, files_content: dict[str, str], call_id: str) -> None: + self.files_content = files_content + self.call_id = call_id + self.temp_base_dir = None + self.temp_files = [] - temp_dirs = set() - for temp_file in temp_files: - try: - if os.path.exists(temp_file): - _logger.debug('Removing temp file: %s', temp_file) - os.remove(temp_file) - temp_dirs.add(os.path.dirname(temp_file)) - except OSError as e: - _logger.warning('Failed to remove temp file %s: %s', temp_file, e) - - for temp_dir in temp_dirs: - try: - if os.path.exists(temp_dir) and not os.listdir(temp_dir): - _logger.debug('Removing temp directory: %s', temp_dir) - os.rmdir(temp_dir) - except OSError as e: - _logger.warning('Failed to remove temp directory %s: %s', temp_dir, e) + def __enter__(self) -> list[str]: + self.temp_base_dir = tempfile.mkdtemp(prefix='cycode_mcp_', suffix=self.call_id) + _logger.debug('Creating temporary files in directory: %s', self.temp_base_dir) + + for file_path, content in self.files_content.items(): + try: + sanitized_path = _sanitize_file_path(file_path) + temp_file_path = os.path.join(self.temp_base_dir, sanitized_path) + + # Ensure the normalized path is still within our temp directory + normalized_temp_path = os.path.normpath(temp_file_path) + normalized_base_path = os.path.normpath(self.temp_base_dir) + if not normalized_temp_path.startswith(normalized_base_path + os.sep): + raise ValueError(f'Path escapes temporary directory: {file_path}') + + os.makedirs(os.path.dirname(temp_file_path), exist_ok=True) + + _logger.debug('Creating temp file: %s (from: %s)', temp_file_path, file_path) + with open(temp_file_path, 'w', encoding='UTF-8') as f: + f.write(content) + + self.temp_files.append(temp_file_path) + except ValueError as e: + _logger.error('Invalid file path rejected: %s - %s', file_path, str(e)) + continue + except Exception as e: + _logger.error('Failed to create temp file for %s: %s', file_path, str(e)) + continue + + if not self.temp_files: + raise ValueError('No valid files provided after sanitization') + + return self.temp_files + + def __exit__(self, *_) -> None: + if self.temp_base_dir and os.path.exists(self.temp_base_dir): + _logger.debug('Removing temp directory recursively: %s', self.temp_base_dir) + shutil.rmtree(self.temp_base_dir, ignore_errors=True) async def _run_cycode_scan(scan_type: ScanTypeOption, temp_files: list[str]) -> dict[str, Any]: @@ -153,21 +182,36 @@ async def _cycode_scan_tool(scan_type: ScanTypeOption, files: dict[str, str] = _ _logger.error('No files provided for scan') return json.dumps({'error': 'No files provided'}) - temp_files = _create_temp_files(files) - try: - _logger.info( - 'Running Cycode scan, %s', - {'scan_type': scan_type, 'files_count': len(temp_files), 'call_id': _tool_call_id}, - ) - result = await _run_cycode_scan(scan_type, temp_files) - _logger.info('Scan completed, %s', {'scan_type': scan_type, 'call_id': _tool_call_id}) - return json.dumps(result, indent=2) + with _TempFilesManager(files, _tool_call_id) as temp_files: + original_count = len(files) + processed_count = len(temp_files) + + if processed_count < original_count: + _logger.warning( + 'Some files were rejected during sanitization, %s', + { + 'scan_type': scan_type, + 'original_count': original_count, + 'processed_count': processed_count, + 'call_id': _tool_call_id, + }, + ) + + _logger.info( + 'Running Cycode scan, %s', + {'scan_type': scan_type, 'files_count': processed_count, 'call_id': _tool_call_id}, + ) + result = await _run_cycode_scan(scan_type, temp_files) + + _logger.info('Scan completed, %s', {'scan_type': scan_type, 'call_id': _tool_call_id}) + return json.dumps(result, indent=2) + except ValueError as e: + _logger.error('Invalid input files, %s', {'scan_type': scan_type, 'call_id': _tool_call_id, 'error': str(e)}) + return json.dumps({'error': f'Invalid input files: {e!s}'}, indent=2) except Exception as e: _logger.error('Scan failed, %s', {'scan_type': scan_type, 'call_id': _tool_call_id, 'error': str(e)}) return json.dumps({'error': f'Scan failed: {e!s}'}, indent=2) - finally: - _cleanup_temp_files(temp_files) async def cycode_secret_scan(files: dict[str, str] = _FILES_TOOL_FIELD) -> str: @@ -197,6 +241,10 @@ async def cycode_sca_scan(files: dict[str, str] = _FILES_TOOL_FIELD) -> str: - verify software supply chain security - review package.json, requirements.txt, pom.xml and other dependency files + Important: + You must also include lock files (like package-lock.json, Pipfile.lock, etc.) to get accurate results. + You must provide manifest and lock files together. + Args: files: Dictionary mapping file paths to their content diff --git a/poetry.lock b/poetry.lock index cdaff5cf..67c78b46 100644 --- a/poetry.lock +++ b/poetry.lock @@ -644,6 +644,23 @@ files = [ {file = "patch-ng-1.18.1.tar.gz", hash = "sha256:52fd46ee46f6c8667692682c1fd7134edc65a2d2d084ebec1d295a6087fc0291"}, ] +[[package]] +name = "pathvalidate" +version = "3.3.1" +description = "pathvalidate is a Python library to sanitize/validate a string such as filenames/file-paths/etc." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f"}, + {file = "pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177"}, +] + +[package.extras] +docs = ["Sphinx (>=2.4)", "sphinx_rtd_theme (>=1.2.2)", "urllib3 (<2)"] +readme = ["path (>=13,<18)", "readmemaker (>=1.2.0)"] +test = ["Faker (>=1.0.8)", "allpairspy (>=2)", "click (>=6.2)", "pytest (>=6.0.1)", "pytest-md-report (>=0.6.2)"] + [[package]] name = "pefile" version = "2024.8.26" @@ -1541,4 +1558,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9,<3.14" -content-hash = "2a401929c8b999931e32f020bd794e9dc3716647bacc79afa70b7791ab86ce00" +content-hash = "a32a53bea8963df5ec2c1a0db09804b8e9466e523488326c20b3f6dd21dee6d2" diff --git a/pyproject.toml b/pyproject.toml index 022d8206..8fce7854 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ typer = "^0.15.3" tenacity = ">=9.0.0,<9.1.0" mcp = { version = ">=1.9.3,<2.0.0", markers = "python_version >= '3.10'" } pydantic = ">=2.11.5,<3.0.0" +pathvalidate = ">=3.3.1,<4.0.0" [tool.poetry.group.test.dependencies] mock = ">=4.0.3,<4.1.0" From 63d97fea110d61535734827d699ef4411dba65f4 Mon Sep 17 00:00:00 2001 From: Amit Moskovitz Date: Tue, 15 Jul 2025 12:19:02 +0300 Subject: [PATCH 180/257] CM-50264 - Add pnpm lock file support for SCA (#323) --- cycode/cli/consts.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 74a9758c..c82cedf6 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -72,6 +72,7 @@ 'package.json', 'package-lock.json', 'yarn.lock', + 'pnpm-lock.yaml', 'npm-shrinkwrap.json', 'packages.config', 'project.assets.json', @@ -126,7 +127,7 @@ 'go': ['go.sum', 'go.mod', 'go.mod.graph', 'Gopkg.lock'], 'maven_pom': ['pom.xml'], 'maven_gradle': ['build.gradle', 'build.gradle.kts', 'gradle.lockfile'], - 'npm': ['package.json', 'package-lock.json', 'yarn.lock', 'npm-shrinkwrap.json', '.npmrc'], + 'npm': ['package.json', 'package-lock.json', 'yarn.lock', 'npm-shrinkwrap.json', '.npmrc', 'pnpm-lock.yaml'], 'nuget': ['packages.config', 'project.assets.json', 'packages.lock.json', 'nuget.config'], 'ruby_gems': ['Gemfile', 'Gemfile.lock'], 'sbt': ['build.sbt', 'build.scala', 'build.sbt.lock'], From a838ebd63608d1b17fa49f657804bc5aefcb0528 Mon Sep 17 00:00:00 2001 From: omerr-cycode Date: Thu, 17 Jul 2025 12:04:45 +0300 Subject: [PATCH 181/257] CM-50594 - Add `--maven-settings-file` SCA option (#324) --- cycode/cli/apps/scan/scan_command.py | 11 ++++++++++ .../sca/maven/restore_maven_dependencies.py | 22 +++++++++++++------ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 363c409a..9b6f9e8b 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -88,6 +88,16 @@ def scan_command( rich_help_panel=_SCA_RICH_HELP_PANEL, ), ] = False, + maven_settings_file: Annotated[ + Optional[Path], + typer.Option( + '--maven-settings-file', + show_default=False, + help='When specified, Cycode will use this settings.xml file when building the maven dependency tree.', + dir_okay=False, + rich_help_panel=_SCA_RICH_HELP_PANEL, + ), + ] = None, export_type: Annotated[ ExportTypeOption, typer.Option( @@ -143,6 +153,7 @@ def scan_command( ctx.obj['sync'] = sync ctx.obj['severity_threshold'] = severity_threshold ctx.obj['monitor'] = monitor + ctx.obj['maven_settings_file'] = maven_settings_file ctx.obj['report'] = report scan_client = get_scan_cycode_client(ctx) diff --git a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py index 589a0a2c..51c91aa9 100644 --- a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py @@ -24,7 +24,12 @@ def is_project(self, document: Document) -> bool: return path.basename(document.path).split('/')[-1] == BUILD_MAVEN_FILE_NAME def get_commands(self, manifest_file_path: str) -> list[list[str]]: - return [['mvn', 'org.cyclonedx:cyclonedx-maven-plugin:2.7.4:makeAggregateBom', '-f', manifest_file_path]] + command = ['mvn', 'org.cyclonedx:cyclonedx-maven-plugin:2.7.4:makeAggregateBom', '-f', manifest_file_path] + + maven_settings_file = self.ctx.obj.get('maven_settings_file') + if maven_settings_file: + command += ['-s', str(maven_settings_file)] + return [command] def get_lock_file_name(self) -> str: return join_paths('target', MAVEN_CYCLONE_DEP_TREE_FILE_NAME) @@ -46,7 +51,7 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: def restore_from_secondary_command(self, document: Document, manifest_file_path: str) -> Optional[Document]: restore_content = execute_commands( - commands=create_secondary_restore_commands(manifest_file_path), + commands=self.create_secondary_restore_commands(manifest_file_path), timeout=self.command_timeout, working_directory=self.get_working_directory(document), ) @@ -61,10 +66,8 @@ def restore_from_secondary_command(self, document: Document, manifest_file_path: absolute_path=restore_file_path, ) - -def create_secondary_restore_commands(manifest_file_path: str) -> list[list[str]]: - return [ - [ + def create_secondary_restore_commands(self, manifest_file_path: str) -> list[list[str]]: + command = [ 'mvn', 'dependency:tree', '-B', @@ -73,4 +76,9 @@ def create_secondary_restore_commands(manifest_file_path: str) -> list[list[str] manifest_file_path, f'-DoutputFile={MAVEN_DEP_TREE_FILE_NAME}', ] - ] + + maven_settings_file = self.ctx.obj.get('maven_settings_file') + if maven_settings_file: + command += ['-s', str(maven_settings_file)] + + return [command] From 810acbeef3641f06cbd490a3c1419f2657ffde88 Mon Sep 17 00:00:00 2001 From: omerr-cycode Date: Thu, 17 Jul 2025 12:51:29 +0300 Subject: [PATCH 182/257] CM-50594 - Update SCA `--maven-settings-file` option in README (#326) Co-authored-by: Ilya Siamionau --- README.md | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 9604bd59..e257afba 100644 --- a/README.md +++ b/README.md @@ -538,18 +538,19 @@ This information can be helpful when: The Cycode CLI application offers several types of scans so that you can choose the option that best fits your case. The following are the current options and commands available: -| Option | Description | -|------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------| -| `-t, --scan-type [secret\|iac\|sca\|sast]` | Specify the scan you wish to execute (`secret`/`iac`/`sca`/`sast`), the default is `secret`. | -| `--show-secret BOOLEAN` | Show secrets in plain text. See [Show/Hide Secrets](#showhide-secrets) section for more details. | -| `--soft-fail BOOLEAN` | Run scan without failing, always return a non-error status code. See [Soft Fail](#soft-fail) section for more details. | -| `--severity-threshold [INFO\|LOW\|MEDIUM\|HIGH\|CRITICAL]` | Show only violations at the specified level or higher. | -| `--sca-scan` | Specify the SCA scan you wish to execute (`package-vulnerabilities`/`license-compliance`). The default is both. | -| `--monitor` | When specified, the scan results will be recorded in Cycode. | -| `--cycode-report` | Display a link to the scan report in the Cycode platform in the console output. | -| `--no-restore` | When specified, Cycode will not run the restore command. This will scan direct dependencies ONLY! | -| `--gradle-all-sub-projects` | Run gradle restore command for all sub projects. This should be run from the project root directory ONLY! | -| `--help` | Show options for given command. | +| Option | Description | +|------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------| +| `-t, --scan-type [secret\|iac\|sca\|sast]` | Specify the scan you wish to execute (`secret`/`iac`/`sca`/`sast`), the default is `secret`. | +| `--show-secret BOOLEAN` | Show secrets in plain text. See [Show/Hide Secrets](#showhide-secrets) section for more details. | +| `--soft-fail BOOLEAN` | Run scan without failing, always return a non-error status code. See [Soft Fail](#soft-fail) section for more details. | +| `--severity-threshold [INFO\|LOW\|MEDIUM\|HIGH\|CRITICAL]` | Show only violations at the specified level or higher. | +| `--sca-scan` | Specify the SCA scan you wish to execute (`package-vulnerabilities`/`license-compliance`). The default is both. | +| `--monitor` | When specified, the scan results will be recorded in Cycode. | +| `--cycode-report` | Display a link to the scan report in the Cycode platform in the console output. | +| `--no-restore` | When specified, Cycode will not run the restore command. This will scan direct dependencies ONLY! | +| `--gradle-all-sub-projects` | Run gradle restore command for all sub projects. This should be run from | +| `--maven-settings-file` | For Maven only, allows using a custom [settings.xml](https://maven.apache.org/settings.html) file when scanning for dependencies | +| `--help` | Show options for given command. | | Command | Description | |----------------------------------------|-----------------------------------------------------------------| From 590e61f760d68b4ef4d8c69c879e1b2ae077cae4 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Thu, 17 Jul 2025 12:05:25 +0200 Subject: [PATCH 183/257] CM-50645 - Fix secrets commit history scan (#327) --- Dockerfile | 2 +- .../files_collector/commit_range_documents.py | 5 +- .../user_settings/configuration_manager.py | 2 +- cycode/cli/utils/yaml_utils.py | 16 +- poetry.lock | 276 +++++++++++++++++- 5 files changed, 290 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8867d1f2..034a1d96 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM python:3.12.9-alpine3.21 AS base WORKDIR /usr/cycode/app -RUN apk add git=2.47.2-r0 +RUN apk add git=2.47.3-r0 FROM base AS builder ENV POETRY_VERSION=1.8.3 diff --git a/cycode/cli/files_collector/commit_range_documents.py b/cycode/cli/files_collector/commit_range_documents.py index 68d18978..0fdcad22 100644 --- a/cycode/cli/files_collector/commit_range_documents.py +++ b/cycode/cli/files_collector/commit_range_documents.py @@ -193,7 +193,10 @@ def get_diff_file_path(diff: 'Diff', relative: bool = False) -> Optional[str]: if diff.b_blob: return diff.b_blob.abspath - return diff.a_blob.abspath + if diff.a_blob: + return diff.a_blob.abspath + + return None def get_diff_file_content(diff: 'Diff') -> str: diff --git a/cycode/cli/user_settings/configuration_manager.py b/cycode/cli/user_settings/configuration_manager.py index 3b83f1c9..85fd7eac 100644 --- a/cycode/cli/user_settings/configuration_manager.py +++ b/cycode/cli/user_settings/configuration_manager.py @@ -82,7 +82,7 @@ def add_exclusion(self, scope: str, scan_type: str, exclusion_type: str, value: @staticmethod def _merge_exclusions(local_exclusions: dict, global_exclusions: dict) -> dict: keys = set(list(local_exclusions.keys()) + list(global_exclusions.keys())) - return {key: local_exclusions.get(key, []) + global_exclusions.get(key, []) for key in keys} + return {key: (local_exclusions.get(key) or []) + (global_exclusions.get(key) or []) for key in keys} def get_or_create_installation_id(self) -> str: config_file_manager = self.get_config_file_manager() diff --git a/cycode/cli/utils/yaml_utils.py b/cycode/cli/utils/yaml_utils.py index c89e1a5c..c53d1ad1 100644 --- a/cycode/cli/utils/yaml_utils.py +++ b/cycode/cli/utils/yaml_utils.py @@ -4,6 +4,10 @@ import yaml +from cycode.logger import get_logger + +logger = get_logger('YAML Utils') + def _deep_update(source: dict[Hashable, Any], overrides: dict[Hashable, Any]) -> dict[Hashable, Any]: for key, value in overrides.items(): @@ -15,10 +19,16 @@ def _deep_update(source: dict[Hashable, Any], overrides: dict[Hashable, Any]) -> return source -def _yaml_safe_load(file: TextIO) -> dict[Hashable, Any]: +def _yaml_object_safe_load(file: TextIO) -> dict[Hashable, Any]: # loader.get_single_data could return None loaded_file = yaml.safe_load(file) - if loaded_file is None: + + if not isinstance(loaded_file, dict): + # forbid literals at the top level + logger.debug( + 'YAML file does not contain a dictionary at the top level: %s', + {'filename': file.name, 'actual_type': type(loaded_file)}, + ) return {} return loaded_file @@ -29,7 +39,7 @@ def read_yaml_file(filename: str) -> dict[Hashable, Any]: return {} with open(filename, encoding='UTF-8') as file: - return _yaml_safe_load(file) + return _yaml_object_safe_load(file) def write_yaml_file(filename: str, content: dict[Hashable, Any]) -> None: diff --git a/poetry.lock b/poetry.lock index 67c78b46..01552065 100644 --- a/poetry.lock +++ b/poetry.lock @@ -69,6 +69,27 @@ types-python-dateutil = ">=2.8.10" doc = ["doc8", "sphinx (>=7.0.0)", "sphinx-autobuild", "sphinx-autodoc-typehints", "sphinx_rtd_theme (>=1.3.0)"] test = ["dateparser (==1.*)", "pre-commit", "pytest", "pytest-cov", "pytest-mock", "pytz (==2021.1)", "simplejson (==3.*)"] +[[package]] +name = "attrs" +version = "25.3.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "python_version >= \"3.10\"" +files = [ + {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"}, + {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"}, +] + +[package.extras] +benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier"] +tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] + [[package]] name = "binaryornot" version = "0.4.4" @@ -502,6 +523,45 @@ files = [ {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] +[[package]] +name = "jsonschema" +version = "4.24.0" +description = "An implementation of JSON Schema validation for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.10\"" +files = [ + {file = "jsonschema-4.24.0-py3-none-any.whl", hash = "sha256:a462455f19f5faf404a7902952b6f0e3ce868f3ee09a359b05eca6673bd8412d"}, + {file = "jsonschema-4.24.0.tar.gz", hash = "sha256:0b4e8069eb12aedfa881333004bccaec24ecef5a8a6a4b6df142b2cc9599d196"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +jsonschema-specifications = ">=2023.03.6" +referencing = ">=0.28.4" +rpds-py = ">=0.7.1" + +[package.extras] +format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=24.6.0)"] + +[[package]] +name = "jsonschema-specifications" +version = "2025.4.1" +description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.10\"" +files = [ + {file = "jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af"}, + {file = "jsonschema_specifications-2025.4.1.tar.gz", hash = "sha256:630159c9f4dbea161a6a2205c3011cc4f18ff381b189fff48bb39b9bf26ae608"}, +] + +[package.dependencies] +referencing = ">=0.31.0" + [[package]] name = "macholib" version = "1.16.3" @@ -565,30 +625,32 @@ tests = ["pytest", "pytz", "simplejson"] [[package]] name = "mcp" -version = "1.9.3" +version = "1.11.0" description = "Model Context Protocol SDK" optional = false python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "mcp-1.9.3-py3-none-any.whl", hash = "sha256:69b0136d1ac9927402ed4cf221d4b8ff875e7132b0b06edd446448766f34f9b9"}, - {file = "mcp-1.9.3.tar.gz", hash = "sha256:587ba38448e81885e5d1b84055cfcc0ca56d35cd0c58f50941cab01109405388"}, + {file = "mcp-1.11.0-py3-none-any.whl", hash = "sha256:58deac37f7483e4b338524b98bc949b7c2b7c33d978f5fafab5bde041c5e2595"}, + {file = "mcp-1.11.0.tar.gz", hash = "sha256:49a213df56bb9472ff83b3132a4825f5c8f5b120a90246f08b0dac6bedac44c8"}, ] [package.dependencies] anyio = ">=4.5" httpx = ">=0.27" httpx-sse = ">=0.4" -pydantic = ">=2.7.2,<3.0.0" +jsonschema = ">=4.20.0" +pydantic = ">=2.8.0,<3.0.0" pydantic-settings = ">=2.5.2" python-multipart = ">=0.0.9" +pywin32 = {version = ">=310", markers = "sys_platform == \"win32\""} sse-starlette = ">=1.6.1" starlette = ">=0.27" uvicorn = {version = ">=0.23.1", markers = "sys_platform != \"emscripten\""} [package.extras] -cli = ["python-dotenv (>=1.0.0)", "typer (>=0.12.4)"] +cli = ["python-dotenv (>=1.0.0)", "typer (>=0.16.0)"] rich = ["rich (>=13.9.4)"] ws = ["websockets (>=15.0.1)"] @@ -1032,6 +1094,37 @@ files = [ {file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"}, ] +[[package]] +name = "pywin32" +version = "311" +description = "Python for Window Extensions" +optional = false +python-versions = "*" +groups = ["main"] +markers = "python_version >= \"3.10\" and sys_platform == \"win32\"" +files = [ + {file = "pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3"}, + {file = "pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b"}, + {file = "pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b"}, + {file = "pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151"}, + {file = "pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503"}, + {file = "pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2"}, + {file = "pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31"}, + {file = "pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067"}, + {file = "pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852"}, + {file = "pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d"}, + {file = "pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d"}, + {file = "pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a"}, + {file = "pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee"}, + {file = "pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87"}, + {file = "pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42"}, + {file = "pywin32-311-cp38-cp38-win32.whl", hash = "sha256:6c6f2969607b5023b0d9ce2541f8d2cbb01c4f46bc87456017cf63b73f1e2d8c"}, + {file = "pywin32-311-cp38-cp38-win_amd64.whl", hash = "sha256:c8015b09fb9a5e188f83b7b04de91ddca4658cee2ae6f3bc483f0b21a77ef6cd"}, + {file = "pywin32-311-cp39-cp39-win32.whl", hash = "sha256:aba8f82d551a942cb20d4a83413ccbac30790b50efb89a75e4f586ac0bb8056b"}, + {file = "pywin32-311-cp39-cp39-win_amd64.whl", hash = "sha256:e0c4cfb0621281fe40387df582097fd796e80430597cb9944f0ae70447bacd91"}, + {file = "pywin32-311-cp39-cp39-win_arm64.whl", hash = "sha256:62ea666235135fee79bb154e695f3ff67370afefd71bd7fea7512fc70ef31e3d"}, +] + [[package]] name = "pywin32-ctypes" version = "0.2.3" @@ -1108,6 +1201,24 @@ files = [ {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, ] +[[package]] +name = "referencing" +version = "0.36.2" +description = "JSON Referencing + Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.10\"" +files = [ + {file = "referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0"}, + {file = "referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +rpds-py = ">=0.7.0" +typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} + [[package]] name = "requests" version = "2.32.4" @@ -1171,6 +1282,161 @@ typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.1 [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] +[[package]] +name = "rpds-py" +version = "0.26.0" +description = "Python bindings to Rust's persistent data structures (rpds)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.10\"" +files = [ + {file = "rpds_py-0.26.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:4c70c70f9169692b36307a95f3d8c0a9fcd79f7b4a383aad5eaa0e9718b79b37"}, + {file = "rpds_py-0.26.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:777c62479d12395bfb932944e61e915741e364c843afc3196b694db3d669fcd0"}, + {file = "rpds_py-0.26.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec671691e72dff75817386aa02d81e708b5a7ec0dec6669ec05213ff6b77e1bd"}, + {file = "rpds_py-0.26.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6a1cb5d6ce81379401bbb7f6dbe3d56de537fb8235979843f0d53bc2e9815a79"}, + {file = "rpds_py-0.26.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4f789e32fa1fb6a7bf890e0124e7b42d1e60d28ebff57fe806719abb75f0e9a3"}, + {file = "rpds_py-0.26.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c55b0a669976cf258afd718de3d9ad1b7d1fe0a91cd1ab36f38b03d4d4aeaaf"}, + {file = "rpds_py-0.26.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c70d9ec912802ecfd6cd390dadb34a9578b04f9bcb8e863d0a7598ba5e9e7ccc"}, + {file = "rpds_py-0.26.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3021933c2cb7def39d927b9862292e0f4c75a13d7de70eb0ab06efed4c508c19"}, + {file = "rpds_py-0.26.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8a7898b6ca3b7d6659e55cdac825a2e58c638cbf335cde41f4619e290dd0ad11"}, + {file = "rpds_py-0.26.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:12bff2ad9447188377f1b2794772f91fe68bb4bbfa5a39d7941fbebdbf8c500f"}, + {file = "rpds_py-0.26.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:191aa858f7d4902e975d4cf2f2d9243816c91e9605070aeb09c0a800d187e323"}, + {file = "rpds_py-0.26.0-cp310-cp310-win32.whl", hash = "sha256:b37a04d9f52cb76b6b78f35109b513f6519efb481d8ca4c321f6a3b9580b3f45"}, + {file = "rpds_py-0.26.0-cp310-cp310-win_amd64.whl", hash = "sha256:38721d4c9edd3eb6670437d8d5e2070063f305bfa2d5aa4278c51cedcd508a84"}, + {file = "rpds_py-0.26.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9e8cb77286025bdb21be2941d64ac6ca016130bfdcd228739e8ab137eb4406ed"}, + {file = "rpds_py-0.26.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5e09330b21d98adc8ccb2dbb9fc6cb434e8908d4c119aeaa772cb1caab5440a0"}, + {file = "rpds_py-0.26.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c9c1b92b774b2e68d11193dc39620d62fd8ab33f0a3c77ecdabe19c179cdbc1"}, + {file = "rpds_py-0.26.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:824e6d3503ab990d7090768e4dfd9e840837bae057f212ff9f4f05ec6d1975e7"}, + {file = "rpds_py-0.26.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ad7fd2258228bf288f2331f0a6148ad0186b2e3643055ed0db30990e59817a6"}, + {file = "rpds_py-0.26.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0dc23bbb3e06ec1ea72d515fb572c1fea59695aefbffb106501138762e1e915e"}, + {file = "rpds_py-0.26.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d80bf832ac7b1920ee29a426cdca335f96a2b5caa839811803e999b41ba9030d"}, + {file = "rpds_py-0.26.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0919f38f5542c0a87e7b4afcafab6fd2c15386632d249e9a087498571250abe3"}, + {file = "rpds_py-0.26.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d422b945683e409000c888e384546dbab9009bb92f7c0b456e217988cf316107"}, + {file = "rpds_py-0.26.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:77a7711fa562ba2da1aa757e11024ad6d93bad6ad7ede5afb9af144623e5f76a"}, + {file = "rpds_py-0.26.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238e8c8610cb7c29460e37184f6799547f7e09e6a9bdbdab4e8edb90986a2318"}, + {file = "rpds_py-0.26.0-cp311-cp311-win32.whl", hash = "sha256:893b022bfbdf26d7bedb083efeea624e8550ca6eb98bf7fea30211ce95b9201a"}, + {file = "rpds_py-0.26.0-cp311-cp311-win_amd64.whl", hash = "sha256:87a5531de9f71aceb8af041d72fc4cab4943648d91875ed56d2e629bef6d4c03"}, + {file = "rpds_py-0.26.0-cp311-cp311-win_arm64.whl", hash = "sha256:de2713f48c1ad57f89ac25b3cb7daed2156d8e822cf0eca9b96a6f990718cc41"}, + {file = "rpds_py-0.26.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:894514d47e012e794f1350f076c427d2347ebf82f9b958d554d12819849a369d"}, + {file = "rpds_py-0.26.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc921b96fa95a097add244da36a1d9e4f3039160d1d30f1b35837bf108c21136"}, + {file = "rpds_py-0.26.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e1157659470aa42a75448b6e943c895be8c70531c43cb78b9ba990778955582"}, + {file = "rpds_py-0.26.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:521ccf56f45bb3a791182dc6b88ae5f8fa079dd705ee42138c76deb1238e554e"}, + {file = "rpds_py-0.26.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9def736773fd56b305c0eef698be5192c77bfa30d55a0e5885f80126c4831a15"}, + {file = "rpds_py-0.26.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdad4ea3b4513b475e027be79e5a0ceac8ee1c113a1a11e5edc3c30c29f964d8"}, + {file = "rpds_py-0.26.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82b165b07f416bdccf5c84546a484cc8f15137ca38325403864bfdf2b5b72f6a"}, + {file = "rpds_py-0.26.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d04cab0a54b9dba4d278fe955a1390da3cf71f57feb78ddc7cb67cbe0bd30323"}, + {file = "rpds_py-0.26.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:79061ba1a11b6a12743a2b0f72a46aa2758613d454aa6ba4f5a265cc48850158"}, + {file = "rpds_py-0.26.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f405c93675d8d4c5ac87364bb38d06c988e11028a64b52a47158a355079661f3"}, + {file = "rpds_py-0.26.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dafd4c44b74aa4bed4b250f1aed165b8ef5de743bcca3b88fc9619b6087093d2"}, + {file = "rpds_py-0.26.0-cp312-cp312-win32.whl", hash = "sha256:3da5852aad63fa0c6f836f3359647870e21ea96cf433eb393ffa45263a170d44"}, + {file = "rpds_py-0.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:cf47cfdabc2194a669dcf7a8dbba62e37a04c5041d2125fae0233b720da6f05c"}, + {file = "rpds_py-0.26.0-cp312-cp312-win_arm64.whl", hash = "sha256:20ab1ae4fa534f73647aad289003f1104092890849e0266271351922ed5574f8"}, + {file = "rpds_py-0.26.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:696764a5be111b036256c0b18cd29783fab22154690fc698062fc1b0084b511d"}, + {file = "rpds_py-0.26.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e6c15d2080a63aaed876e228efe4f814bc7889c63b1e112ad46fdc8b368b9e1"}, + {file = "rpds_py-0.26.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:390e3170babf42462739a93321e657444f0862c6d722a291accc46f9d21ed04e"}, + {file = "rpds_py-0.26.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7da84c2c74c0f5bc97d853d9e17bb83e2dcafcff0dc48286916001cc114379a1"}, + {file = "rpds_py-0.26.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c5fe114a6dd480a510b6d3661d09d67d1622c4bf20660a474507aaee7eeeee9"}, + {file = "rpds_py-0.26.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3100b3090269f3a7ea727b06a6080d4eb7439dca4c0e91a07c5d133bb1727ea7"}, + {file = "rpds_py-0.26.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c03c9b0c64afd0320ae57de4c982801271c0c211aa2d37f3003ff5feb75bb04"}, + {file = "rpds_py-0.26.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5963b72ccd199ade6ee493723d18a3f21ba7d5b957017607f815788cef50eaf1"}, + {file = "rpds_py-0.26.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9da4e873860ad5bab3291438525cae80169daecbfafe5657f7f5fb4d6b3f96b9"}, + {file = "rpds_py-0.26.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5afaddaa8e8c7f1f7b4c5c725c0070b6eed0228f705b90a1732a48e84350f4e9"}, + {file = "rpds_py-0.26.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4916dc96489616a6f9667e7526af8fa693c0fdb4f3acb0e5d9f4400eb06a47ba"}, + {file = "rpds_py-0.26.0-cp313-cp313-win32.whl", hash = "sha256:2a343f91b17097c546b93f7999976fd6c9d5900617aa848c81d794e062ab302b"}, + {file = "rpds_py-0.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:0a0b60701f2300c81b2ac88a5fb893ccfa408e1c4a555a77f908a2596eb875a5"}, + {file = "rpds_py-0.26.0-cp313-cp313-win_arm64.whl", hash = "sha256:257d011919f133a4746958257f2c75238e3ff54255acd5e3e11f3ff41fd14256"}, + {file = "rpds_py-0.26.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:529c8156d7506fba5740e05da8795688f87119cce330c244519cf706a4a3d618"}, + {file = "rpds_py-0.26.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f53ec51f9d24e9638a40cabb95078ade8c99251945dad8d57bf4aabe86ecee35"}, + {file = "rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab504c4d654e4a29558eaa5bb8cea5fdc1703ea60a8099ffd9c758472cf913f"}, + {file = "rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd0641abca296bc1a00183fe44f7fced8807ed49d501f188faa642d0e4975b83"}, + {file = "rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:69b312fecc1d017b5327afa81d4da1480f51c68810963a7336d92203dbb3d4f1"}, + {file = "rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c741107203954f6fc34d3066d213d0a0c40f7bb5aafd698fb39888af277c70d8"}, + {file = "rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc3e55a7db08dc9a6ed5fb7103019d2c1a38a349ac41901f9f66d7f95750942f"}, + {file = "rpds_py-0.26.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9e851920caab2dbcae311fd28f4313c6953993893eb5c1bb367ec69d9a39e7ed"}, + {file = "rpds_py-0.26.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dfbf280da5f876d0b00c81f26bedce274e72a678c28845453885a9b3c22ae632"}, + {file = "rpds_py-0.26.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:1cc81d14ddfa53d7f3906694d35d54d9d3f850ef8e4e99ee68bc0d1e5fed9a9c"}, + {file = "rpds_py-0.26.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dca83c498b4650a91efcf7b88d669b170256bf8017a5db6f3e06c2bf031f57e0"}, + {file = "rpds_py-0.26.0-cp313-cp313t-win32.whl", hash = "sha256:4d11382bcaf12f80b51d790dee295c56a159633a8e81e6323b16e55d81ae37e9"}, + {file = "rpds_py-0.26.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff110acded3c22c033e637dd8896e411c7d3a11289b2edf041f86663dbc791e9"}, + {file = "rpds_py-0.26.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:da619979df60a940cd434084355c514c25cf8eb4cf9a508510682f6c851a4f7a"}, + {file = "rpds_py-0.26.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ea89a2458a1a75f87caabefe789c87539ea4e43b40f18cff526052e35bbb4fdf"}, + {file = "rpds_py-0.26.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feac1045b3327a45944e7dcbeb57530339f6b17baff154df51ef8b0da34c8c12"}, + {file = "rpds_py-0.26.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b818a592bd69bfe437ee8368603d4a2d928c34cffcdf77c2e761a759ffd17d20"}, + {file = "rpds_py-0.26.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a8b0dd8648709b62d9372fc00a57466f5fdeefed666afe3fea5a6c9539a0331"}, + {file = "rpds_py-0.26.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d3498ad0df07d81112aa6ec6c95a7e7b1ae00929fb73e7ebee0f3faaeabad2f"}, + {file = "rpds_py-0.26.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24a4146ccb15be237fdef10f331c568e1b0e505f8c8c9ed5d67759dac58ac246"}, + {file = "rpds_py-0.26.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a9a63785467b2d73635957d32a4f6e73d5e4df497a16a6392fa066b753e87387"}, + {file = "rpds_py-0.26.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:de4ed93a8c91debfd5a047be327b7cc8b0cc6afe32a716bbbc4aedca9e2a83af"}, + {file = "rpds_py-0.26.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:caf51943715b12af827696ec395bfa68f090a4c1a1d2509eb4e2cb69abbbdb33"}, + {file = "rpds_py-0.26.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4a59e5bc386de021f56337f757301b337d7ab58baa40174fb150accd480bc953"}, + {file = "rpds_py-0.26.0-cp314-cp314-win32.whl", hash = "sha256:92c8db839367ef16a662478f0a2fe13e15f2227da3c1430a782ad0f6ee009ec9"}, + {file = "rpds_py-0.26.0-cp314-cp314-win_amd64.whl", hash = "sha256:b0afb8cdd034150d4d9f53926226ed27ad15b7f465e93d7468caaf5eafae0d37"}, + {file = "rpds_py-0.26.0-cp314-cp314-win_arm64.whl", hash = "sha256:ca3f059f4ba485d90c8dc75cb5ca897e15325e4e609812ce57f896607c1c0867"}, + {file = "rpds_py-0.26.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5afea17ab3a126006dc2f293b14ffc7ef3c85336cf451564a0515ed7648033da"}, + {file = "rpds_py-0.26.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:69f0c0a3df7fd3a7eec50a00396104bb9a843ea6d45fcc31c2d5243446ffd7a7"}, + {file = "rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:801a71f70f9813e82d2513c9a96532551fce1e278ec0c64610992c49c04c2dad"}, + {file = "rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:df52098cde6d5e02fa75c1f6244f07971773adb4a26625edd5c18fee906fa84d"}, + {file = "rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9bc596b30f86dc6f0929499c9e574601679d0341a0108c25b9b358a042f51bca"}, + {file = "rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9dfbe56b299cf5875b68eb6f0ebaadc9cac520a1989cac0db0765abfb3709c19"}, + {file = "rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac64f4b2bdb4ea622175c9ab7cf09444e412e22c0e02e906978b3b488af5fde8"}, + {file = "rpds_py-0.26.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:181ef9b6bbf9845a264f9aa45c31836e9f3c1f13be565d0d010e964c661d1e2b"}, + {file = "rpds_py-0.26.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:49028aa684c144ea502a8e847d23aed5e4c2ef7cadfa7d5eaafcb40864844b7a"}, + {file = "rpds_py-0.26.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e5d524d68a474a9688336045bbf76cb0def88549c1b2ad9dbfec1fb7cfbe9170"}, + {file = "rpds_py-0.26.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1851f429b822831bd2edcbe0cfd12ee9ea77868f8d3daf267b189371671c80e"}, + {file = "rpds_py-0.26.0-cp314-cp314t-win32.whl", hash = "sha256:7bdb17009696214c3b66bb3590c6d62e14ac5935e53e929bcdbc5a495987a84f"}, + {file = "rpds_py-0.26.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f14440b9573a6f76b4ee4770c13f0b5921f71dde3b6fcb8dabbefd13b7fe05d7"}, + {file = "rpds_py-0.26.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:7a48af25d9b3c15684059d0d1fc0bc30e8eee5ca521030e2bffddcab5be40226"}, + {file = "rpds_py-0.26.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0c71c2f6bf36e61ee5c47b2b9b5d47e4d1baad6426bfed9eea3e858fc6ee8806"}, + {file = "rpds_py-0.26.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d815d48b1804ed7867b539236b6dd62997850ca1c91cad187f2ddb1b7bbef19"}, + {file = "rpds_py-0.26.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:84cfbd4d4d2cdeb2be61a057a258d26b22877266dd905809e94172dff01a42ae"}, + {file = "rpds_py-0.26.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fbaa70553ca116c77717f513e08815aec458e6b69a028d4028d403b3bc84ff37"}, + {file = "rpds_py-0.26.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39bfea47c375f379d8e87ab4bb9eb2c836e4f2069f0f65731d85e55d74666387"}, + {file = "rpds_py-0.26.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1533b7eb683fb5f38c1d68a3c78f5fdd8f1412fa6b9bf03b40f450785a0ab915"}, + {file = "rpds_py-0.26.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c5ab0ee51f560d179b057555b4f601b7df909ed31312d301b99f8b9fc6028284"}, + {file = "rpds_py-0.26.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e5162afc9e0d1f9cae3b577d9c29ddbab3505ab39012cb794d94a005825bde21"}, + {file = "rpds_py-0.26.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:43f10b007033f359bc3fa9cd5e6c1e76723f056ffa9a6b5c117cc35720a80292"}, + {file = "rpds_py-0.26.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e3730a48e5622e598293eee0762b09cff34dd3f271530f47b0894891281f051d"}, + {file = "rpds_py-0.26.0-cp39-cp39-win32.whl", hash = "sha256:4b1f66eb81eab2e0ff5775a3a312e5e2e16bf758f7b06be82fb0d04078c7ac51"}, + {file = "rpds_py-0.26.0-cp39-cp39-win_amd64.whl", hash = "sha256:519067e29f67b5c90e64fb1a6b6e9d2ec0ba28705c51956637bac23a2f4ddae1"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3c0909c5234543ada2515c05dc08595b08d621ba919629e94427e8e03539c958"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:c1fb0cda2abcc0ac62f64e2ea4b4e64c57dfd6b885e693095460c61bde7bb18e"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84d142d2d6cf9b31c12aa4878d82ed3b2324226270b89b676ac62ccd7df52d08"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a547e21c5610b7e9093d870be50682a6a6cf180d6da0f42c47c306073bfdbbf6"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:35e9a70a0f335371275cdcd08bc5b8051ac494dd58bff3bbfb421038220dc871"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0dfa6115c6def37905344d56fb54c03afc49104e2ca473d5dedec0f6606913b4"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:313cfcd6af1a55a286a3c9a25f64af6d0e46cf60bc5798f1db152d97a216ff6f"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f7bf2496fa563c046d05e4d232d7b7fd61346e2402052064b773e5c378bf6f73"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:aa81873e2c8c5aa616ab8e017a481a96742fdf9313c40f14338ca7dbf50cb55f"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:68ffcf982715f5b5b7686bdd349ff75d422e8f22551000c24b30eaa1b7f7ae84"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6188de70e190847bb6db3dc3981cbadff87d27d6fe9b4f0e18726d55795cee9b"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:1c962145c7473723df9722ba4c058de12eb5ebedcb4e27e7d902920aa3831ee8"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f61a9326f80ca59214d1cceb0a09bb2ece5b2563d4e0cd37bfd5515c28510674"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:183f857a53bcf4b1b42ef0f57ca553ab56bdd170e49d8091e96c51c3d69ca696"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:941c1cfdf4799d623cf3aa1d326a6b4fdb7a5799ee2687f3516738216d2262fb"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72a8d9564a717ee291f554eeb4bfeafe2309d5ec0aa6c475170bdab0f9ee8e88"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:511d15193cbe013619dd05414c35a7dedf2088fcee93c6bbb7c77859765bd4e8"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aea1f9741b603a8d8fedb0ed5502c2bc0accbc51f43e2ad1337fe7259c2b77a5"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4019a9d473c708cf2f16415688ef0b4639e07abaa569d72f74745bbeffafa2c7"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:093d63b4b0f52d98ebae33b8c50900d3d67e0666094b1be7a12fffd7f65de74b"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2abe21d8ba64cded53a2a677e149ceb76dcf44284202d737178afe7ba540c1eb"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:4feb7511c29f8442cbbc28149a92093d32e815a28aa2c50d333826ad2a20fdf0"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:e99685fc95d386da368013e7fb4269dd39c30d99f812a8372d62f244f662709c"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a90a13408a7a856b87be8a9f008fff53c5080eea4e4180f6c2e546e4a972fb5d"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:3ac51b65e8dc76cf4949419c54c5528adb24fc721df722fd452e5fbc236f5c40"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59b2093224a18c6508d95cfdeba8db9cbfd6f3494e94793b58972933fcee4c6d"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4f01a5d6444a3258b00dc07b6ea4733e26f8072b788bef750baa37b370266137"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6e2c12160c72aeda9d1283e612f68804621f448145a210f1bf1d79151c47090"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cb28c1f569f8d33b2b5dcd05d0e6ef7005d8639c54c2f0be824f05aedf715255"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1766b5724c3f779317d5321664a343c07773c8c5fd1532e4039e6cc7d1a815be"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b6d9e5a2ed9c4988c8f9b28b3bc0e3e5b1aaa10c28d210a594ff3a8c02742daf"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:b5f7a446ddaf6ca0fad9a5535b56fbfc29998bf0e0b450d174bbec0d600e1d72"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:eed5ac260dd545fbc20da5f4f15e7efe36a55e0e7cf706e4ec005b491a9546a0"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:582462833ba7cee52e968b0341b85e392ae53d44c0f9af6a5927c80e539a8b67"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:69a607203441e07e9a8a529cff1d5b73f6a160f22db1097211e6212a68567d11"}, + {file = "rpds_py-0.26.0.tar.gz", hash = "sha256:20dae58a859b0906f0685642e591056f1e787f3a8b39c8e8749a45dc7d26bdb0"}, +] + [[package]] name = "ruff" version = "0.11.7" From 540e340873839a6e30411d86444065b185fe34ba Mon Sep 17 00:00:00 2001 From: LironKotev Date: Wed, 6 Aug 2025 14:20:43 +0300 Subject: [PATCH 184/257] CM-50468 - Add deno lock file support for SCA (#328) --- cycode/cli/consts.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index c82cedf6..c223110b 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -72,6 +72,7 @@ 'package.json', 'package-lock.json', 'yarn.lock', + 'deno.lock', 'pnpm-lock.yaml', 'npm-shrinkwrap.json', 'packages.config', @@ -127,7 +128,15 @@ 'go': ['go.sum', 'go.mod', 'go.mod.graph', 'Gopkg.lock'], 'maven_pom': ['pom.xml'], 'maven_gradle': ['build.gradle', 'build.gradle.kts', 'gradle.lockfile'], - 'npm': ['package.json', 'package-lock.json', 'yarn.lock', 'npm-shrinkwrap.json', '.npmrc', 'pnpm-lock.yaml'], + 'npm': [ + 'package.json', + 'package-lock.json', + 'yarn.lock', + 'npm-shrinkwrap.json', + '.npmrc', + 'pnpm-lock.yaml', + 'deno.lock', + ], 'nuget': ['packages.config', 'project.assets.json', 'packages.lock.json', 'nuget.config'], 'ruby_gems': ['Gemfile', 'Gemfile.lock'], 'sbt': ['build.sbt', 'build.scala', 'build.sbt.lock'], From be92b19e0b8d5ea98332897347708a2863c4c5e2 Mon Sep 17 00:00:00 2001 From: LironKotev Date: Thu, 7 Aug 2025 11:47:53 +0300 Subject: [PATCH 185/257] CM-50468 - Add deno json file support for SCA (#329) --- cycode/cli/consts.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index c223110b..18939156 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -73,6 +73,7 @@ 'package-lock.json', 'yarn.lock', 'deno.lock', + 'deno.json', 'pnpm-lock.yaml', 'npm-shrinkwrap.json', 'packages.config', @@ -136,6 +137,7 @@ '.npmrc', 'pnpm-lock.yaml', 'deno.lock', + 'deno.json', ], 'nuget': ['packages.config', 'project.assets.json', 'packages.lock.json', 'nuget.config'], 'ruby_gems': ['Gemfile', 'Gemfile.lock'], From 51399f335db7fcc2bfe318d15dd418be31231506 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Mon, 25 Aug 2025 13:27:48 +0200 Subject: [PATCH 186/257] CM-52199 - Fix `--gradle-all-sub-projects` and `--no-restore` scan options (#330) --- cycode/cli/consts.py | 4 ---- .../files_collector/sca/maven/restore_gradle_dependencies.py | 2 +- cycode/cli/files_collector/sca/sca_file_collector.py | 2 +- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 18939156..be1af35f 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -280,10 +280,6 @@ # Result: A -> ... -> C SCA_SHORTCUT_DEPENDENCY_PATHS = 2 -SCA_SKIP_RESTORE_DEPENDENCIES_FLAG = 'no-restore' - -SCA_GRADLE_ALL_SUB_PROJECTS_FLAG = 'gradle-all-sub-projects' - PLASTIC_VCS_DATA_SEPARATOR = ':::' PLASTIC_VSC_CLI_TIMEOUT = 10 PLASTIC_VCS_REMOTE_URI_PREFIX = 'plastic::' diff --git a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py index 777ae727..ed9494ad 100644 --- a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py @@ -27,7 +27,7 @@ def __init__( self.projects = self.get_all_projects() if self.is_gradle_sub_projects() else projects def is_gradle_sub_projects(self) -> bool: - return self.ctx.params.get('gradle-all-sub-projects', False) + return self.ctx.params.get('gradle_all_sub_projects', False) def is_project(self, document: Document) -> bool: return document.path.endswith(BUILD_GRADLE_FILE_NAME) or document.path.endswith(BUILD_GRADLE_KTS_FILE_NAME) diff --git a/cycode/cli/files_collector/sca/sca_file_collector.py b/cycode/cli/files_collector/sca/sca_file_collector.py index e3ed22f8..35ee36c7 100644 --- a/cycode/cli/files_collector/sca/sca_file_collector.py +++ b/cycode/cli/files_collector/sca/sca_file_collector.py @@ -165,6 +165,6 @@ def _add_dependencies_tree_documents( def add_sca_dependencies_tree_documents_if_needed( ctx: typer.Context, scan_type: str, documents_to_scan: list[Document], is_git_diff: bool = False ) -> None: - no_restore = ctx.params.get('no-restore', False) + no_restore = ctx.params.get('no_restore', False) if scan_type == consts.SCA_SCAN_TYPE and not no_restore: _add_dependencies_tree_documents(ctx, documents_to_scan, is_git_diff) From af953f339998ee1ae9b073a5590812c2dda5149f Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Mon, 25 Aug 2025 15:47:38 +0200 Subject: [PATCH 187/257] CM-52199 - Fix --gradle-all-sub-projects and --no-restore scan options (#331) --- cycode/cli/apps/scan/scan_command.py | 4 ++-- .../files_collector/sca/maven/restore_gradle_dependencies.py | 2 +- cycode/cli/files_collector/sca/sca_file_collector.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 9b6f9e8b..dda94876 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -155,6 +155,8 @@ def scan_command( ctx.obj['monitor'] = monitor ctx.obj['maven_settings_file'] = maven_settings_file ctx.obj['report'] = report + ctx.obj['gradle_all_sub_projects'] = gradle_all_sub_projects + ctx.obj['no_restore'] = no_restore scan_client = get_scan_cycode_client(ctx) ctx.obj['client'] = scan_client @@ -167,8 +169,6 @@ def scan_command( console_printer = ctx.obj['console_printer'] console_printer.enable_recording(export_type, export_file) - _ = no_restore, gradle_all_sub_projects # they are actually used; via ctx.params - _sca_scan_to_context(ctx, sca_scan) diff --git a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py index ed9494ad..50830243 100644 --- a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py @@ -27,7 +27,7 @@ def __init__( self.projects = self.get_all_projects() if self.is_gradle_sub_projects() else projects def is_gradle_sub_projects(self) -> bool: - return self.ctx.params.get('gradle_all_sub_projects', False) + return self.ctx.obj.get('gradle_all_sub_projects', False) def is_project(self, document: Document) -> bool: return document.path.endswith(BUILD_GRADLE_FILE_NAME) or document.path.endswith(BUILD_GRADLE_KTS_FILE_NAME) diff --git a/cycode/cli/files_collector/sca/sca_file_collector.py b/cycode/cli/files_collector/sca/sca_file_collector.py index 35ee36c7..84b088b2 100644 --- a/cycode/cli/files_collector/sca/sca_file_collector.py +++ b/cycode/cli/files_collector/sca/sca_file_collector.py @@ -165,6 +165,6 @@ def _add_dependencies_tree_documents( def add_sca_dependencies_tree_documents_if_needed( ctx: typer.Context, scan_type: str, documents_to_scan: list[Document], is_git_diff: bool = False ) -> None: - no_restore = ctx.params.get('no_restore', False) + no_restore = ctx.obj.get('no_restore', False) if scan_type == consts.SCA_SCAN_TYPE and not no_restore: _add_dependencies_tree_documents(ctx, documents_to_scan, is_git_diff) From 98070af13b9427f0a68c39274a2b7beec9812b0e Mon Sep 17 00:00:00 2001 From: Dmytro Lynda <58661187+DmytroLynda@users.noreply.github.com> Date: Tue, 26 Aug 2025 17:38:26 +0200 Subject: [PATCH 188/257] CM-52106 - Add `.cycodeignore` support (#332) --- cycode/cli/files_collector/walk_ignore.py | 3 ++- tests/cli/files_collector/test_walk_ignore.py | 26 ++++++++++++++----- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/cycode/cli/files_collector/walk_ignore.py b/cycode/cli/files_collector/walk_ignore.py index 35855ff4..f0e8edd6 100644 --- a/cycode/cli/files_collector/walk_ignore.py +++ b/cycode/cli/files_collector/walk_ignore.py @@ -4,8 +4,9 @@ from cycode.cli.logger import logger from cycode.cli.utils.ignore_utils import IgnoreFilterManager -_SUPPORTED_IGNORE_PATTERN_FILES = { # oneday we will bring .cycodeignore or something like that +_SUPPORTED_IGNORE_PATTERN_FILES = { '.gitignore', + '.cycodeignore', } _DEFAULT_GLOBAL_IGNORE_PATTERNS = [ '.git', diff --git a/tests/cli/files_collector/test_walk_ignore.py b/tests/cli/files_collector/test_walk_ignore.py index 12b9d428..ffcf46cb 100644 --- a/tests/cli/files_collector/test_walk_ignore.py +++ b/tests/cli/files_collector/test_walk_ignore.py @@ -52,17 +52,22 @@ def _create_mocked_file_structure(fs: 'FakeFilesystem') -> None: fs.create_file('/home/user/project/.git/HEAD') fs.create_file('/home/user/project/.gitignore', contents='*.pyc\n*.log') + fs.create_file('/home/user/project/.cycodeignore', contents='*.cs') fs.create_file('/home/user/project/ignored.pyc') + fs.create_file('/home/user/project/ignored.cs') fs.create_file('/home/user/project/presented.txt') fs.create_file('/home/user/project/ignored2.log') fs.create_file('/home/user/project/ignored2.pyc') + fs.create_file('/home/user/project/ignored2.cs') fs.create_file('/home/user/project/presented2.txt') fs.create_dir('/home/user/project/subproject') fs.create_file('/home/user/project/subproject/.gitignore', contents='*.txt') + fs.create_file('/home/user/project/subproject/.cycodeignore', contents='*.cs') fs.create_file('/home/user/project/subproject/ignored.txt') fs.create_file('/home/user/project/subproject/ignored.log') fs.create_file('/home/user/project/subproject/ignored.pyc') + fs.create_file('/home/user/project/subproject/ignored.cs') fs.create_file('/home/user/project/subproject/presented.py') @@ -72,23 +77,27 @@ def test_collect_top_level_ignore_files(fs: 'FakeFilesystem') -> None: # Test with path inside the project path = normpath('/home/user/project/subproject') ignore_files = _collect_top_level_ignore_files(path) - assert len(ignore_files) == 2 - assert normpath('/home/user/project/subproject/.gitignore') in ignore_files + assert len(ignore_files) == 4 assert normpath('/home/user/project/.gitignore') in ignore_files + assert normpath('/home/user/project/subproject/.gitignore') in ignore_files + assert normpath('/home/user/project/.cycodeignore') in ignore_files + assert normpath('/home/user/project/subproject/.cycodeignore') in ignore_files # Test with path at the top level with no ignore files path = normpath('/home/user/.git') ignore_files = _collect_top_level_ignore_files(path) assert len(ignore_files) == 0 - # Test with path at the top level with a .gitignore + # Test with path at the top level with ignore files path = normpath('/home/user/project') ignore_files = _collect_top_level_ignore_files(path) - assert len(ignore_files) == 1 + assert len(ignore_files) == 2 assert normpath('/home/user/project/.gitignore') in ignore_files + assert normpath('/home/user/project/.cycodeignore') in ignore_files # Test with a path that does not have any ignore files fs.remove('/home/user/project/.gitignore') + fs.remove('/home/user/project/.cycodeignore') path = normpath('/home/user') ignore_files = _collect_top_level_ignore_files(path) assert len(ignore_files) == 0 @@ -110,19 +119,24 @@ def test_walk_ignore(fs: 'FakeFilesystem') -> None: path = normpath('/home/user/project') result = _collect_walk_ignore_files(path) - assert len(result) == 5 + assert len(result) == 7 # ignored globally by default: assert normpath('/home/user/project/.git/HEAD') not in result assert normpath('/home/user/project/.cycode/config.yaml') not in result # ignored by .gitignore in project directory: assert normpath('/home/user/project/ignored.pyc') not in result assert normpath('/home/user/project/subproject/ignored.pyc') not in result + # ignored by .cycodeignore in project directory: + assert normpath('/home/user/project/ignored.cs') not in result + assert normpath('/home/user/project/subproject/ignored.cs') not in result # ignored by .gitignore in subproject directory: assert normpath('/home/user/project/subproject/ignored.txt') not in result # ignored by .cycodeignore in project directory: assert normpath('/home/user/project/ignored2.log') not in result assert normpath('/home/user/project/ignored2.pyc') not in result assert normpath('/home/user/project/subproject/ignored.log') not in result + # ignored by .cycodeignore in subproject directory: + assert normpath('/home/user/project/ignored2.cs') not in result # presented after both .gitignore and .cycodeignore: assert normpath('/home/user/project/.gitignore') in result assert normpath('/home/user/project/subproject/.gitignore') in result @@ -133,7 +147,7 @@ def test_walk_ignore(fs: 'FakeFilesystem') -> None: path = normpath('/home/user/project/subproject') result = _collect_walk_ignore_files(path) - assert len(result) == 2 + assert len(result) == 3 # ignored: assert normpath('/home/user/project/subproject/ignored.txt') not in result assert normpath('/home/user/project/subproject/ignored.log') not in result From 82414392145a2018b7987d517b89f339c4256b8a Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 27 Aug 2025 18:00:11 +0200 Subject: [PATCH 189/257] CM-52347 - Update README to better explain diff scanning capabilities via commit range (#334) --- README.md | 75 ++++++++++++++++++++++++++------ cycode/cli/apps/scan/__init__.py | 2 +- 2 files changed, 63 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index e257afba..95f9e309 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ This guide walks you through both installation and usage. 3. [Path Scan](#path-scan) 1. [Terraform Plan Scan](#terraform-plan-scan) 4. [Commit History Scan](#commit-history-scan) - 1. [Commit Range Option](#commit-range-option) + 1. [Commit Range Option (Diff Scanning)](#commit-range-option-diff-scanning) 5. [Pre-Commit Scan](#pre-commit-scan) 2. [Scan Results](#scan-results) 1. [Show/Hide Secrets](#showhide-secrets) @@ -552,12 +552,12 @@ The Cycode CLI application offers several types of scans so that you can choose | `--maven-settings-file` | For Maven only, allows using a custom [settings.xml](https://maven.apache.org/settings.html) file when scanning for dependencies | | `--help` | Show options for given command. | -| Command | Description | -|----------------------------------------|-----------------------------------------------------------------| -| [commit-history](#commit-history-scan) | Scan all the commits history in this git repository | -| [path](#path-scan) | Scan the files in the path supplied in the command | -| [pre-commit](#pre-commit-scan) | Use this command to scan the content that was not committed yet | -| [repository](#repository-scan) | Scan git repository including its history | +| Command | Description | +|----------------------------------------|-----------------------------------------------------------------------| +| [commit-history](#commit-history-scan) | Scan commit history or perform diff scanning between specific commits | +| [path](#path-scan) | Scan the files in the path supplied in the command | +| [pre-commit](#pre-commit-scan) | Use this command to scan the content that was not committed yet | +| [repository](#repository-scan) | Scan git repository including its history | ### Options @@ -701,9 +701,16 @@ If you just have a configuration file, you can generate a plan by doing the foll ### Commit History Scan > [!NOTE] -> Secrets scanning analyzes all commits in the repository history because secrets introduced and later removed can still be leaked or exposed. SCA and SAST scanning focus only on the latest code state and the changes between branches or pull requests. Full commit history scanning is not performed for SCA and SAST. +> Commit History Scan is not available for IaC scans. -A commit history scan is limited to a local repository’s previous commits, focused on finding any secrets within the commit history, instead of examining the repository’s current state. +The commit history scan command provides two main capabilities: + +1. **Full History Scanning**: Analyze all commits in the repository history +2. **Diff Scanning**: Scan only the changes between specific commits + +Secrets scanning can analyze all commits in the repository history because secrets introduced and later removed can still be leaked or exposed. For SCA and SAST scans, the commit history command focuses on scanning the differences/changes between commits, making it perfect for pull request reviews and incremental scanning. + +A commit history scan examines your Git repository's commit history and can be used both for comprehensive historical analysis and targeted diff scanning of specific changes. To execute a commit history scan, execute the following: @@ -719,13 +726,55 @@ The following options are available for use with this command: |---------------------------|----------------------------------------------------------------------------------------------------------| | `-r, --commit-range TEXT` | Scan a commit range in this git repository, by default cycode scans all commit history (example: HEAD~1) | -#### Commit Range Option +#### Commit Range Option (Diff Scanning) + +The commit range option enables **diff scanning** – scanning only the changes between specific commits instead of the entire repository history. +This is particularly useful for: +- **Pull request validation**: Scan only the changes introduced in a PR +- **Incremental CI/CD scanning**: Focus on recent changes rather than the entire codebase +- **Feature branch review**: Compare changes against main/master branch +- **Performance optimization**: Faster scans by limiting scope to relevant changes + +#### Commit Range Syntax + +The `--commit-range` (`-r`) option supports standard Git revision syntax: + +| Syntax | Description | Example | +|---------------------|-----------------------------------|-------------------------| +| `commit1..commit2` | Changes from commit1 to commit2 | `abc123..def456` | +| `commit1...commit2` | Changes in commit2 not in commit1 | `main...feature-branch` | +| `commit` | Changes from commit to HEAD | `HEAD~1` | +| `branch1..branch2` | Changes from branch1 to branch2 | `main..feature-branch` | + +#### Diff Scanning Examples + +**Scan changes in the last commit:** +```bash +cycode scan commit-history -r HEAD~1 ~/home/git/codebase +``` + +**Scan changes between two specific commits:** +```bash +cycode scan commit-history -r abc123..def456 ~/home/git/codebase +``` -The commit history scan, by default, examines the repository’s entire commit history, all the way back to the initial commit. You can instead limit the scan to a specific commit range by adding the argument `--commit-range` (`-r`) followed by the name you specify. +**Scan changes in your feature branch compared to main:** +```bash +cycode scan commit-history -r main..HEAD ~/home/git/codebase +``` -Consider the previous example. If you wanted to scan only specific commits in your repository, you could execute the following: +**Scan changes between main and a feature branch:** +```bash +cycode scan commit-history -r main..feature-branch ~/home/git/codebase +``` -`cycode scan commit-history -r {{from-commit-id}}...{{to-commit-id}} ~/home/git/codebase` +**Scan all changes in the last 3 commits:** +```bash +cycode scan commit-history -r HEAD~3..HEAD ~/home/git/codebase +``` + +> [!TIP] +> For CI/CD pipelines, you can use environment variables like `${{ github.event.pull_request.base.sha }}..${{ github.sha }}` (GitHub Actions) or `$CI_MERGE_REQUEST_TARGET_BRANCH_SHA..$CI_COMMIT_SHA` (GitLab CI) to scan only PR/MR changes. ### Pre-Commit Scan diff --git a/cycode/cli/apps/scan/__init__.py b/cycode/cli/apps/scan/__init__.py index b4d8ab79..07611c58 100644 --- a/cycode/cli/apps/scan/__init__.py +++ b/cycode/cli/apps/scan/__init__.py @@ -20,7 +20,7 @@ app.command(name='path', short_help='Scan the files in the paths provided in the command.')(path_command) app.command(name='repository', short_help='Scan the Git repository included files.')(repository_command) -app.command(name='commit-history', short_help='Scan all the commits history in this Git repository.')( +app.command(name='commit-history', short_help='Scan commit history or perform diff scanning between specific commits.')( commit_history_command ) app.command( From 5f874a59688f82ec5216924d80b6198b03282130 Mon Sep 17 00:00:00 2001 From: oz-blumenfeld Date: Wed, 27 Aug 2025 21:47:57 +0300 Subject: [PATCH 190/257] CM-51659 - Add customization of timeouts for dependency trees (SCA restore) (#333) --- .../sca/maven/restore_gradle_dependencies.py | 3 +-- .../files_collector/sca/sca_file_collector.py | 16 +++++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py index 50830243..75e1e8f7 100644 --- a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py @@ -12,7 +12,6 @@ BUILD_GRADLE_FILE_NAME = 'build.gradle' BUILD_GRADLE_KTS_FILE_NAME = 'build.gradle.kts' BUILD_GRADLE_DEP_TREE_FILE_NAME = 'gradle-dependencies-generated.txt' -BUILD_GRADLE_ALL_PROJECTS_TIMEOUT = 180 BUILD_GRADLE_ALL_PROJECTS_COMMAND = ['gradle', 'projects'] ALL_PROJECTS_REGEX = r"[+-]{3} Project '(.*?)'" @@ -48,7 +47,7 @@ def get_working_directory(self, document: Document) -> Optional[str]: def get_all_projects(self) -> set[str]: output = shell( command=BUILD_GRADLE_ALL_PROJECTS_COMMAND, - timeout=BUILD_GRADLE_ALL_PROJECTS_TIMEOUT, + timeout=self.command_timeout, working_directory=get_path_from_context(self.ctx), ) if not output: diff --git a/cycode/cli/files_collector/sca/sca_file_collector.py b/cycode/cli/files_collector/sca/sca_file_collector.py index 84b088b2..0c206c66 100644 --- a/cycode/cli/files_collector/sca/sca_file_collector.py +++ b/cycode/cli/files_collector/sca/sca_file_collector.py @@ -1,3 +1,4 @@ +import os from typing import TYPE_CHECKING, Optional import typer @@ -122,14 +123,15 @@ def _try_restore_dependencies( def _get_restore_handlers(ctx: typer.Context, is_git_diff: bool) -> list[BaseRestoreDependencies]: + build_dep_tree_timeout = int(os.getenv('CYCODE_BUILD_DEP_TREE_TIMEOUT_SECONDS', BUILD_DEP_TREE_TIMEOUT)) return [ - RestoreGradleDependencies(ctx, is_git_diff, BUILD_DEP_TREE_TIMEOUT), - RestoreMavenDependencies(ctx, is_git_diff, BUILD_DEP_TREE_TIMEOUT), - RestoreSbtDependencies(ctx, is_git_diff, BUILD_DEP_TREE_TIMEOUT), - RestoreGoDependencies(ctx, is_git_diff, BUILD_DEP_TREE_TIMEOUT), - RestoreNugetDependencies(ctx, is_git_diff, BUILD_DEP_TREE_TIMEOUT), - RestoreNpmDependencies(ctx, is_git_diff, BUILD_DEP_TREE_TIMEOUT), - RestoreRubyDependencies(ctx, is_git_diff, BUILD_DEP_TREE_TIMEOUT), + RestoreGradleDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestoreMavenDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestoreSbtDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestoreGoDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestoreNugetDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestoreNpmDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestoreRubyDependencies(ctx, is_git_diff, build_dep_tree_timeout), ] From d0e9a8effed5e2a407c27e582c68df9af6d55af2 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Mon, 1 Sep 2025 16:05:38 +0200 Subject: [PATCH 191/257] CM-51974 - Fix Git remote url resolving by searching in parent dirs; fix handling of multiplied paths to scan (#335) --- cycode/cli/apps/scan/remote_url_resolver.py | 39 ++++++++++++++++++--- cycode/cli/apps/scan/scan_parameters.py | 20 +++-------- 2 files changed, 39 insertions(+), 20 deletions(-) diff --git a/cycode/cli/apps/scan/remote_url_resolver.py b/cycode/cli/apps/scan/remote_url_resolver.py index 5f96328d..967e6ea0 100644 --- a/cycode/cli/apps/scan/remote_url_resolver.py +++ b/cycode/cli/apps/scan/remote_url_resolver.py @@ -99,17 +99,46 @@ def _try_to_get_plastic_remote_url(path: str) -> Optional[str]: def _try_get_git_remote_url(path: str) -> Optional[str]: try: - remote_url = git_proxy.get_repo(path).remotes[0].config_reader.get('url') - logger.debug('Found Git remote URL, %s', {'remote_url': remote_url, 'path': path}) + repo = git_proxy.get_repo(path, search_parent_directories=True) + remote_url = repo.remotes[0].config_reader.get('url') + logger.debug('Found Git remote URL, %s', {'remote_url': remote_url, 'repo_path': repo.working_dir}) return remote_url - except Exception: - logger.debug('Failed to get Git remote URL. Probably not a Git repository') + except Exception as e: + logger.debug('Failed to get Git remote URL. Probably not a Git repository', exc_info=e) return None -def try_get_any_remote_url(path: str) -> Optional[str]: +def _try_get_any_remote_url(path: str) -> Optional[str]: remote_url = _try_get_git_remote_url(path) if not remote_url: remote_url = _try_to_get_plastic_remote_url(path) return remote_url + + +def get_remote_url_scan_parameter(paths: tuple[str, ...]) -> Optional[str]: + remote_urls = set() + for path in paths: + # FIXME(MarshalX): perf issue. This looping will produce: + # - len(paths) Git subprocess calls in the worst case + # - len(paths)*2 Plastic SCM subprocess calls + remote_url = _try_get_any_remote_url(path) + if remote_url: + remote_urls.add(remote_url) + + if len(remote_urls) == 1: + # we are resolving remote_url only if all paths belong to the same repo (identical remote URLs), + # otherwise, the behavior is undefined + remote_url = remote_urls.pop() + + logger.debug( + 'Single remote URL found. Scan will be associated with organization, %s', {'remote_url': remote_url} + ) + return remote_url + + logger.debug( + 'Multiple different remote URLs found. Scan will not be associated with organization, %s', + {'remote_urls': remote_urls}, + ) + + return None diff --git a/cycode/cli/apps/scan/scan_parameters.py b/cycode/cli/apps/scan/scan_parameters.py index c3c4ecbe..4d950880 100644 --- a/cycode/cli/apps/scan/scan_parameters.py +++ b/cycode/cli/apps/scan/scan_parameters.py @@ -1,9 +1,8 @@ -import os from typing import Optional import typer -from cycode.cli.apps.scan.remote_url_resolver import try_get_any_remote_url +from cycode.cli.apps.scan.remote_url_resolver import get_remote_url_scan_parameter from cycode.cli.utils.scan_utils import generate_unique_scan_id from cycode.logger import get_logger @@ -29,18 +28,9 @@ def get_scan_parameters(ctx: typer.Context, paths: Optional[tuple[str, ...]] = N scan_parameters['paths'] = paths - if len(paths) != 1: - logger.debug('Multiple paths provided, going to ignore remote url') - return scan_parameters - - if not os.path.isdir(paths[0]): - logger.debug('Path is not a directory, going to ignore remote url') - return scan_parameters - - remote_url = try_get_any_remote_url(paths[0]) - if remote_url: - # TODO(MarshalX): remove hardcode in context - ctx.obj['remote_url'] = remote_url - scan_parameters['remote_url'] = remote_url + remote_url = get_remote_url_scan_parameter(paths) + # TODO(MarshalX): remove hardcode in context + ctx.obj['remote_url'] = remote_url + scan_parameters['remote_url'] = remote_url return scan_parameters From dd321bfcc4b8b6d2805282b4e9bbdd60b82eae7d Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 2 Sep 2025 12:13:43 +0200 Subject: [PATCH 192/257] CM-52497 - Fix SCA folder excluder (#336) --- cycode/cli/consts.py | 2 +- cycode/cli/files_collector/file_excluder.py | 13 +- .../cli/files_collector/test_file_excluder.py | 114 ++++++++++++++++++ 3 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 tests/cli/files_collector/test_file_excluder.py diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index be1af35f..94903552 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -105,7 +105,7 @@ 'conan.lock', ) -SCA_EXCLUDED_PATHS = ( +SCA_EXCLUDED_FOLDER_IN_PATH = ( 'node_modules', 'venv', '.venv', diff --git a/cycode/cli/files_collector/file_excluder.py b/cycode/cli/files_collector/file_excluder.py index 3a117b25..e3c0b41a 100644 --- a/cycode/cli/files_collector/file_excluder.py +++ b/cycode/cli/files_collector/file_excluder.py @@ -1,3 +1,4 @@ +from pathlib import Path from typing import TYPE_CHECKING from cycode.cli import consts @@ -40,11 +41,13 @@ def _does_document_exceed_max_size_limit(content: str) -> bool: def _is_file_relevant_for_sca_scan(filename: str) -> bool: - if any(sca_excluded_path in filename for sca_excluded_path in consts.SCA_EXCLUDED_PATHS): - logger.debug( - 'The file is irrelevant because it is from the inner path of node_modules, %s', {'filename': filename} - ) - return False + for part in Path(filename).parts: + if part in consts.SCA_EXCLUDED_FOLDER_IN_PATH: + logger.debug( + 'The file is irrelevant because it is from an excluded directory, %s', + {'filename': filename, 'excluded_directory': part}, + ) + return False return True diff --git a/tests/cli/files_collector/test_file_excluder.py b/tests/cli/files_collector/test_file_excluder.py new file mode 100644 index 00000000..4ac623e3 --- /dev/null +++ b/tests/cli/files_collector/test_file_excluder.py @@ -0,0 +1,114 @@ +import pytest + +from cycode.cli import consts +from cycode.cli.files_collector.file_excluder import _is_file_relevant_for_sca_scan + + +class TestIsFileRelevantForScaScan: + """Test the SCA path exclusion logic.""" + + def test_files_in_excluded_directories_should_be_excluded(self) -> None: + """Test that files inside excluded directories are properly excluded.""" + + # Test node_modules exclusion + assert _is_file_relevant_for_sca_scan('project/node_modules/package/index.js') is False + assert _is_file_relevant_for_sca_scan('/project/node_modules/package.json') is False + assert _is_file_relevant_for_sca_scan('deep/nested/node_modules/lib/file.txt') is False + + # Test .gradle exclusion + assert _is_file_relevant_for_sca_scan('project/.gradle/wrapper/gradle-wrapper.jar') is False + assert _is_file_relevant_for_sca_scan('/home/user/.gradle/caches/modules.xml') is False + + # Test venv exclusion + assert _is_file_relevant_for_sca_scan('project/venv/lib/python3.8/site-packages/module.py') is False + assert _is_file_relevant_for_sca_scan('/home/user/venv/bin/activate') is False + + # Test __pycache__ exclusion + assert _is_file_relevant_for_sca_scan('src/__pycache__/module.cpython-38.pyc') is False + assert _is_file_relevant_for_sca_scan('project/utils/__pycache__/helper.pyc') is False + + def test_files_with_excluded_names_in_filename_should_be_included(self) -> None: + """Test that files containing excluded directory names in their filename are NOT excluded.""" + + # These should be INCLUDED because the excluded terms are in the filename, not directory path + assert _is_file_relevant_for_sca_scan('project/build.gradle') is True + assert _is_file_relevant_for_sca_scan('project/gradlew') is True + assert _is_file_relevant_for_sca_scan('app/node_modules_backup.txt') is True + assert _is_file_relevant_for_sca_scan('src/venv_setup.py') is True + assert _is_file_relevant_for_sca_scan('utils/pycache_cleaner.py') is True + assert _is_file_relevant_for_sca_scan('config/gradle_config.xml') is True + + def test_files_in_regular_directories_should_be_included(self) -> None: + """Test that files in regular directories (not excluded) are included.""" + + assert _is_file_relevant_for_sca_scan('project/src/main.py') is True + assert _is_file_relevant_for_sca_scan('app/components/button.tsx') is True + assert _is_file_relevant_for_sca_scan('/home/user/project/package.json') is True + assert _is_file_relevant_for_sca_scan('build/dist/app.js') is True + assert _is_file_relevant_for_sca_scan('tests/unit/test_utils.py') is True + + def test_multiple_excluded_directories_in_path(self) -> None: + """Test paths that contain multiple excluded directories.""" + + # Should be excluded if ANY directory in the path is excluded + assert _is_file_relevant_for_sca_scan('project/venv/lib/node_modules/package.json') is False + assert _is_file_relevant_for_sca_scan('app/node_modules/dep/.gradle/build.xml') is False + assert _is_file_relevant_for_sca_scan('src/__pycache__/nested/venv/file.py') is False + + def test_absolute_vs_relative_paths(self) -> None: + """Test both absolute and relative path formats.""" + + # Relative paths + assert _is_file_relevant_for_sca_scan('node_modules/package.json') is False + assert _is_file_relevant_for_sca_scan('src/app.py') is True + + # Absolute paths + assert _is_file_relevant_for_sca_scan('/home/user/project/node_modules/lib.js') is False + assert _is_file_relevant_for_sca_scan('/home/user/project/src/main.py') is True + + def test_edge_cases(self) -> None: + """Test edge cases and boundary conditions.""" + + # Empty string should be considered relevant (no path to exclude) + assert _is_file_relevant_for_sca_scan('') is True + # Single filename without a directory + assert _is_file_relevant_for_sca_scan('package.json') is True + # Root-level excluded directory + assert _is_file_relevant_for_sca_scan('/node_modules/package.json') is False + # Excluded directory as part of the filename but in allowed directory + assert _is_file_relevant_for_sca_scan('src/my_node_modules_file.js') is True + + def test_case_sensitivity(self) -> None: + """Test that directory matching is case-sensitive.""" + + # Excluded directories are lowercase, so uppercase versions should be included + assert _is_file_relevant_for_sca_scan('project/NODE_MODULES/package.json') is True + assert _is_file_relevant_for_sca_scan('project/Node_Modules/lib.js') is True + assert _is_file_relevant_for_sca_scan('project/VENV/lib/module.py') is True + + # But exact case matches should be excluded + assert _is_file_relevant_for_sca_scan('project/node_modules/package.json') is False + assert _is_file_relevant_for_sca_scan('project/venv/lib/module.py') is False + + def test_nested_excluded_directories(self) -> None: + """Test deeply nested directory structures with excluded directories.""" + + # Deep nesting should still work + deep_path = 'a/b/c/d/e/f/g/node_modules/h/i/j/package.json' + assert _is_file_relevant_for_sca_scan(deep_path) is False + + # Multiple levels of excluded directories + multi_excluded = 'project/node_modules/package/venv/lib/__pycache__/module.pyc' + assert _is_file_relevant_for_sca_scan(multi_excluded) is False + + @pytest.mark.parametrize('excluded_dir', consts.SCA_EXCLUDED_FOLDER_IN_PATH) + def test_parametrized_excluded_directories(self, excluded_dir: str) -> None: + """Parametrized test to ensure all excluded directories work correctly.""" + + # File inside excluded directory should be excluded + excluded_path = f'project/{excluded_dir}/file.txt' + assert _is_file_relevant_for_sca_scan(excluded_path) is False + + # File with excluded directory name in filename should be included + included_path = f'project/src/{excluded_dir}_config.txt' + assert _is_file_relevant_for_sca_scan(included_path) is True From b35b6a0c3a5bd59bb32f4490330dead3ffd6c44d Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 2 Sep 2025 12:22:58 +0200 Subject: [PATCH 193/257] CM-52584 - Fix docker image building on PRs from outside contributors (#337) --- .github/workflows/docker-image.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 4101ded8..8a6809b8 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -64,6 +64,7 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Login to Docker Hub + if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') }} uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USER }} From 5457fd10b8e77568c51a90efafda64d834734bc3 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 2 Sep 2025 15:55:21 +0200 Subject: [PATCH 194/257] CM-51935 - Fix work on read-only file systems (#338) --- cycode/cli/user_settings/base_file_manager.py | 11 ++++++++++- cycode/cli/utils/version_checker.py | 7 +++++-- cycode/cli/utils/yaml_utils.py | 7 ++++++- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/cycode/cli/user_settings/base_file_manager.py b/cycode/cli/user_settings/base_file_manager.py index 4f07f11c..6a9d0fe2 100644 --- a/cycode/cli/user_settings/base_file_manager.py +++ b/cycode/cli/user_settings/base_file_manager.py @@ -4,6 +4,9 @@ from typing import Any from cycode.cli.utils.yaml_utils import read_yaml_file, update_yaml_file +from cycode.logger import get_logger + +logger = get_logger('Base File Manager') class BaseFileManager(ABC): @@ -15,5 +18,11 @@ def read_file(self) -> dict[Hashable, Any]: def write_content_to_file(self, content: dict[Hashable, Any]) -> None: filename = self.get_filename() - os.makedirs(os.path.dirname(filename), exist_ok=True) + + try: + os.makedirs(os.path.dirname(filename), exist_ok=True) + except Exception as e: + logger.warning('Failed to create directory for file, %s', {'filename': filename}, exc_info=e) + return + update_yaml_file(filename, content) diff --git a/cycode/cli/utils/version_checker.py b/cycode/cli/utils/version_checker.py index 8fd1d005..24146989 100644 --- a/cycode/cli/utils/version_checker.py +++ b/cycode/cli/utils/version_checker.py @@ -8,6 +8,9 @@ from cycode.cli.user_settings.configuration_manager import ConfigurationManager from cycode.cli.utils.path_utils import get_file_content from cycode.cyclient.cycode_client_base import CycodeClientBase +from cycode.logger import get_logger + +logger = get_logger('Version Checker') def _compare_versions( @@ -154,8 +157,8 @@ def _update_last_check(self) -> None: os.makedirs(os.path.dirname(self.cache_file), exist_ok=True) with open(self.cache_file, 'w', encoding='UTF-8') as f: f.write(str(time.time())) - except OSError: - pass + except Exception as e: + logger.debug('Failed to update version check cache file: %s', {'file': self.cache_file}, exc_info=e) def check_for_update(self, current_version: str, use_cache: bool = True) -> Optional[str]: """Check if an update is available for the current version. diff --git a/cycode/cli/utils/yaml_utils.py b/cycode/cli/utils/yaml_utils.py index c53d1ad1..c92acdc8 100644 --- a/cycode/cli/utils/yaml_utils.py +++ b/cycode/cli/utils/yaml_utils.py @@ -35,7 +35,8 @@ def _yaml_object_safe_load(file: TextIO) -> dict[Hashable, Any]: def read_yaml_file(filename: str) -> dict[Hashable, Any]: - if not os.path.exists(filename): + if not os.access(filename, os.R_OK) or not os.path.exists(filename): + logger.debug('Config file is not accessible or does not exist: %s', {'filename': filename}) return {} with open(filename, encoding='UTF-8') as file: @@ -43,6 +44,10 @@ def read_yaml_file(filename: str) -> dict[Hashable, Any]: def write_yaml_file(filename: str, content: dict[Hashable, Any]) -> None: + if not os.access(filename, os.W_OK) and os.path.exists(filename): + logger.warning('No write permission for file. Cannot save config, %s', {'filename': filename}) + return + with open(filename, 'w', encoding='UTF-8') as file: yaml.safe_dump(content, file) From d7c17b9b45ac5f210b9ca82e9f02fce6ca35040a Mon Sep 17 00:00:00 2001 From: Amit Moskovitz Date: Thu, 4 Sep 2025 12:04:45 +0300 Subject: [PATCH 195/257] CM-52091 - Fix first patched version display in SCA rich output (#340) --- cycode/cli/printers/rich_printer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cycode/cli/printers/rich_printer.py b/cycode/cli/printers/rich_printer.py index 7ee0f853..10cf561c 100644 --- a/cycode/cli/printers/rich_printer.py +++ b/cycode/cli/printers/rich_printer.py @@ -91,7 +91,7 @@ def __add_sca_scan_related_rows(details_table: Table, detection: 'Detection') -> details_table.add_row('Version', detection_details.get('package_version')) if detection.has_alert: - patched_version = detection_details['alert'].get('patched_version') + patched_version = detection_details['alert'].get('first_patched_version') details_table.add_row('First patched version', patched_version or 'Not fixed') dependency_path = detection_details.get('dependency_paths') From bc03b57f6a4df50edcc6101729ea2101cb78c23f Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Fri, 5 Sep 2025 13:41:17 +0200 Subject: [PATCH 196/257] CM-51935 - Fix pre-commit hook for bare repositories (#341) --- cycode/cli/apps/scan/commit_range_scanner.py | 5 +- cycode/cli/consts.py | 1 + .../files_collector/commit_range_documents.py | 42 +++++- .../test_commit_range_documents.py | 130 ++++++++++++++++++ 4 files changed, 170 insertions(+), 8 deletions(-) create mode 100644 tests/cli/files_collector/test_commit_range_documents.py diff --git a/cycode/cli/apps/scan/commit_range_scanner.py b/cycode/cli/apps/scan/commit_range_scanner.py index 5a7893df..390d03a5 100644 --- a/cycode/cli/apps/scan/commit_range_scanner.py +++ b/cycode/cli/apps/scan/commit_range_scanner.py @@ -25,6 +25,7 @@ get_diff_file_content, get_diff_file_path, get_pre_commit_modified_documents, + get_safe_head_reference_for_diff, parse_commit_range_sast, parse_commit_range_sca, ) @@ -271,7 +272,9 @@ def _scan_sca_pre_commit(ctx: typer.Context, repo_path: str) -> None: def _scan_secret_pre_commit(ctx: typer.Context, repo_path: str) -> None: progress_bar = ctx.obj['progress_bar'] - diff_index = git_proxy.get_repo(repo_path).index.diff(consts.GIT_HEAD_COMMIT_REV, create_patch=True, R=True) + repo = git_proxy.get_repo(repo_path) + head_reference = get_safe_head_reference_for_diff(repo) + diff_index = repo.index.diff(head_reference, create_patch=True, R=True) progress_bar.set_section_length(ScanProgressBarSection.PREPARE_LOCAL_FILES, len(diff_index)) diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 94903552..19347b1d 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -261,6 +261,7 @@ # git consts COMMIT_DIFF_DELETED_FILE_CHANGE_TYPE = 'D' GIT_HEAD_COMMIT_REV = 'HEAD' +GIT_EMPTY_TREE_OBJECT = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' EMPTY_COMMIT_SHA = '0000000000000000000000000000000000000000' GIT_PUSH_OPTION_COUNT_ENV_VAR_NAME = 'GIT_PUSH_OPTION_COUNT' GIT_PUSH_OPTION_ENV_VAR_PREFIX = 'GIT_PUSH_OPTION_' diff --git a/cycode/cli/files_collector/commit_range_documents.py b/cycode/cli/files_collector/commit_range_documents.py index 0fdcad22..fa7af193 100644 --- a/cycode/cli/files_collector/commit_range_documents.py +++ b/cycode/cli/files_collector/commit_range_documents.py @@ -22,6 +22,31 @@ logger = get_logger('Commit Range Collector') +def get_safe_head_reference_for_diff(repo: 'Repo') -> str: + """Get a safe reference to use for diffing against the current HEAD. + In repositories with no commits, HEAD doesn't exist, so we return the empty tree hash. + + Args: + repo: Git repository object + + Returns: + Either "HEAD" string if commits exist, or empty tree hash if no commits exist + """ + try: + repo.rev_parse(consts.GIT_HEAD_COMMIT_REV) + return consts.GIT_HEAD_COMMIT_REV + except Exception as e: # actually gitdb.exc.BadObject; no import because of lazy loading + logger.debug( + 'Repository has no commits, using empty tree hash for diffs, %s', + {'repo_path': repo.working_tree_dir}, + exc_info=e, + ) + + # Repository has no commits, use the universal empty tree hash + # This is the standard Git approach for initial commits + return consts.GIT_EMPTY_TREE_OBJECT + + def _does_reach_to_max_commits_to_scan_limit(commit_ids: list[str], max_commits_count: Optional[int]) -> bool: if max_commits_count is None: return False @@ -213,7 +238,8 @@ def get_pre_commit_modified_documents( diff_documents = [] repo = git_proxy.get_repo(repo_path) - diff_index = repo.index.diff(consts.GIT_HEAD_COMMIT_REV, create_patch=True, R=True) + head_reference = get_safe_head_reference_for_diff(repo) + diff_index = repo.index.diff(head_reference, create_patch=True, R=True) progress_bar.set_section_length(progress_bar_section, len(diff_index)) for diff in diff_index: progress_bar.update(progress_bar_section) @@ -228,9 +254,11 @@ def get_pre_commit_modified_documents( ) ) - file_content = _get_file_content_from_commit_diff(repo, consts.GIT_HEAD_COMMIT_REV, diff) - if file_content: - git_head_documents.append(Document(file_path, file_content)) + # Only get file content from HEAD if HEAD exists (not the empty tree hash) + if head_reference == consts.GIT_HEAD_COMMIT_REV: + file_content = _get_file_content_from_commit_diff(repo, head_reference, diff) + if file_content: + git_head_documents.append(Document(file_path, file_content)) if os.path.exists(file_path): file_content = get_file_content(file_path) @@ -274,13 +302,13 @@ def parse_commit_range_sast(commit_range: str, path: str) -> tuple[Optional[str] else: # Git commands like 'git diff ' compare against HEAD. from_spec = commit_range - to_spec = 'HEAD' + to_spec = consts.GIT_HEAD_COMMIT_REV # If a spec is empty (e.g., from '..master'), default it to 'HEAD' if not from_spec: - from_spec = 'HEAD' + from_spec = consts.GIT_HEAD_COMMIT_REV if not to_spec: - to_spec = 'HEAD' + to_spec = consts.GIT_HEAD_COMMIT_REV try: # Use rev_parse to resolve each specifier to its full commit SHA diff --git a/tests/cli/files_collector/test_commit_range_documents.py b/tests/cli/files_collector/test_commit_range_documents.py new file mode 100644 index 00000000..9bf2474f --- /dev/null +++ b/tests/cli/files_collector/test_commit_range_documents.py @@ -0,0 +1,130 @@ +import os +import tempfile +from collections.abc import Generator +from contextlib import contextmanager + +from git import Repo + +from cycode.cli import consts +from cycode.cli.files_collector.commit_range_documents import get_safe_head_reference_for_diff + + +@contextmanager +def git_repository(path: str) -> Generator[Repo, None, None]: + """Context manager for Git repositories that ensures proper cleanup on Windows.""" + repo = Repo.init(path) + try: + yield repo + finally: + # Properly close the repository to release file handles + repo.close() + + +@contextmanager +def temporary_git_repository() -> Generator[tuple[str, Repo], None, None]: + """Combined context manager for temporary directory with Git repository.""" + with tempfile.TemporaryDirectory() as temp_dir, git_repository(temp_dir) as repo: + yield temp_dir, repo + + +class TestGetSafeHeadReferenceForDiff: + """Test the safe HEAD reference functionality for git diff operations.""" + + def test_returns_head_when_repository_has_commits(self) -> None: + """Test that HEAD is returned when the repository has existing commits.""" + with temporary_git_repository() as (temp_dir, repo): + test_file = os.path.join(temp_dir, 'test.py') + with open(test_file, 'w') as f: + f.write("print('test')") + + repo.index.add(['test.py']) + repo.index.commit('Initial commit') + + result = get_safe_head_reference_for_diff(repo) + assert result == consts.GIT_HEAD_COMMIT_REV + + def test_returns_empty_tree_hash_when_repository_has_no_commits(self) -> None: + """Test that an empty tree hash is returned when the repository has no commits.""" + with temporary_git_repository() as (temp_dir, repo): + result = get_safe_head_reference_for_diff(repo) + expected_empty_tree_hash = consts.GIT_EMPTY_TREE_OBJECT + assert result == expected_empty_tree_hash + + +class TestIndexDiffWithSafeHeadReference: + """Test that index.diff works correctly with the safe head reference.""" + + def test_index_diff_works_on_bare_repository(self) -> None: + """Test that index.diff works on repositories with no commits.""" + with temporary_git_repository() as (temp_dir, repo): + test_file = os.path.join(temp_dir, 'staged_file.py') + with open(test_file, 'w') as f: + f.write("print('staged content')") + + repo.index.add(['staged_file.py']) + + head_ref = get_safe_head_reference_for_diff(repo) + diff_index = repo.index.diff(head_ref, create_patch=True, R=True) + + assert len(diff_index) == 1 + diff = diff_index[0] + assert diff.b_path == 'staged_file.py' + + def test_index_diff_works_on_repository_with_commits(self) -> None: + """Test that index.diff continues to work on repositories with existing commits.""" + with temporary_git_repository() as (temp_dir, repo): + initial_file = os.path.join(temp_dir, 'initial.py') + with open(initial_file, 'w') as f: + f.write("print('initial')") + + repo.index.add(['initial.py']) + repo.index.commit('Initial commit') + + new_file = os.path.join(temp_dir, 'new_file.py') + with open(new_file, 'w') as f: + f.write("print('new file')") + + with open(initial_file, 'w') as f: + f.write("print('modified initial')") + + repo.index.add(['new_file.py', 'initial.py']) + + head_ref = get_safe_head_reference_for_diff(repo) + diff_index = repo.index.diff(head_ref, create_patch=True, R=True) + + assert len(diff_index) == 2 + file_paths = {diff.b_path or diff.a_path for diff in diff_index} + assert 'new_file.py' in file_paths + assert 'initial.py' in file_paths + assert head_ref == consts.GIT_HEAD_COMMIT_REV + + def test_sequential_operations_on_same_repository(self) -> None: + """Test behavior when transitioning from bare to committed repository.""" + with temporary_git_repository() as (temp_dir, repo): + test_file = os.path.join(temp_dir, 'test.py') + with open(test_file, 'w') as f: + f.write("print('test')") + + repo.index.add(['test.py']) + + head_ref_before = get_safe_head_reference_for_diff(repo) + diff_before = repo.index.diff(head_ref_before, create_patch=True, R=True) + + expected_empty_tree = consts.GIT_EMPTY_TREE_OBJECT + assert head_ref_before == expected_empty_tree + assert len(diff_before) == 1 + + repo.index.commit('First commit') + + new_file = os.path.join(temp_dir, 'new.py') + with open(new_file, 'w') as f: + f.write("print('new')") + + repo.index.add(['new.py']) + + head_ref_after = get_safe_head_reference_for_diff(repo) + diff_after = repo.index.diff(head_ref_after, create_patch=True, R=True) + + assert head_ref_after == consts.GIT_HEAD_COMMIT_REV + assert len(diff_after) == 1 + assert diff_after[0].b_path == 'new.py' From 89ec2e4a23cd5485c8085db710164bda8968500e Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 9 Sep 2025 17:58:03 +0200 Subject: [PATCH 197/257] CM-52765 - Fix git mv handling for commit range scans (#343) --- README.md | 4 +-- cycode/cli/apps/scan/commit_range_scanner.py | 6 +++- .../files_collector/commit_range_documents.py | 16 +++++++--- .../test_commit_range_documents.py | 29 ++++++++++++++++++- 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 95f9e309..06d12822 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,7 @@ Perform the following steps to install the pre-commit hook: ```yaml repos: - repo: https://github.com/cycodehq/cycode-cli - rev: v3.2.0 + rev: v3.4.2 hooks: - id: cycode stages: @@ -245,7 +245,7 @@ Perform the following steps to install the pre-commit hook: ```yaml repos: - repo: https://github.com/cycodehq/cycode-cli - rev: v3.2.0 + rev: v3.4.2 hooks: - id: cycode stages: diff --git a/cycode/cli/apps/scan/commit_range_scanner.py b/cycode/cli/apps/scan/commit_range_scanner.py index 390d03a5..3abd2940 100644 --- a/cycode/cli/apps/scan/commit_range_scanner.py +++ b/cycode/cli/apps/scan/commit_range_scanner.py @@ -282,7 +282,11 @@ def _scan_secret_pre_commit(ctx: typer.Context, repo_path: str) -> None: for diff in diff_index: progress_bar.update(ScanProgressBarSection.PREPARE_LOCAL_FILES) documents_to_scan.append( - Document(get_path_by_os(get_diff_file_path(diff)), get_diff_file_content(diff), is_git_diff_format=True) + Document( + get_path_by_os(get_diff_file_path(diff, repo=repo)), + get_diff_file_content(diff), + is_git_diff_format=True, + ) ) documents_to_scan = excluder.exclude_irrelevant_documents_to_scan(consts.SECRET_SCAN_TYPE, documents_to_scan) diff --git a/cycode/cli/files_collector/commit_range_documents.py b/cycode/cli/files_collector/commit_range_documents.py index fa7af193..572d0cc2 100644 --- a/cycode/cli/files_collector/commit_range_documents.py +++ b/cycode/cli/files_collector/commit_range_documents.py @@ -87,7 +87,7 @@ def collect_commit_range_diff_documents( for diff in diff_index: commit_documents_to_scan.append( Document( - path=get_path_by_os(get_diff_file_path(diff)), + path=get_path_by_os(get_diff_file_path(diff, repo=repo)), content=get_diff_file_content(diff), is_git_diff_format=True, unique_id=commit_id, @@ -166,7 +166,7 @@ def get_commit_range_modified_documents( for diff in modified_files_diff: progress_bar.update(progress_bar_section) - file_path = get_path_by_os(get_diff_file_path(diff)) + file_path = get_path_by_os(get_diff_file_path(diff, repo=repo)) diff_documents.append( Document( @@ -211,16 +211,24 @@ def parse_pre_receive_input() -> str: return pre_receive_input.splitlines()[0] -def get_diff_file_path(diff: 'Diff', relative: bool = False) -> Optional[str]: +def get_diff_file_path(diff: 'Diff', relative: bool = False, repo: Optional['Repo'] = None) -> Optional[str]: if relative: # relative to the repository root return diff.b_path if diff.b_path else diff.a_path + # Try blob-based paths first (most reliable when available) if diff.b_blob: return diff.b_blob.abspath if diff.a_blob: return diff.a_blob.abspath + # Fallback: construct an absolute path from a relative path + # This handles renames and other cases where blobs might be None + if repo and repo.working_tree_dir: + target_path = diff.b_path if diff.b_path else diff.a_path + if target_path: + return os.path.abspath(os.path.join(repo.working_tree_dir, target_path)) + return None @@ -244,7 +252,7 @@ def get_pre_commit_modified_documents( for diff in diff_index: progress_bar.update(progress_bar_section) - file_path = get_path_by_os(get_diff_file_path(diff)) + file_path = get_path_by_os(get_diff_file_path(diff, repo=repo)) diff_documents.append( Document( diff --git a/tests/cli/files_collector/test_commit_range_documents.py b/tests/cli/files_collector/test_commit_range_documents.py index 9bf2474f..c51db5ad 100644 --- a/tests/cli/files_collector/test_commit_range_documents.py +++ b/tests/cli/files_collector/test_commit_range_documents.py @@ -6,7 +6,11 @@ from git import Repo from cycode.cli import consts -from cycode.cli.files_collector.commit_range_documents import get_safe_head_reference_for_diff +from cycode.cli.files_collector.commit_range_documents import ( + get_diff_file_path, + get_safe_head_reference_for_diff, +) +from cycode.cli.utils.path_utils import get_path_by_os @contextmanager @@ -128,3 +132,26 @@ def test_sequential_operations_on_same_repository(self) -> None: assert head_ref_after == consts.GIT_HEAD_COMMIT_REV assert len(diff_after) == 1 assert diff_after[0].b_path == 'new.py' + + +def test_git_mv_pre_commit_scan() -> None: + with temporary_git_repository() as (temp_dir, repo): + newfile_path = os.path.join(temp_dir, 'NEWFILE.txt') + with open(newfile_path, 'w') as f: + f.write('test content') + + repo.index.add(['NEWFILE.txt']) + repo.index.commit('init') + + # Rename file but don't commit (this is the pre-commit scenario) + renamed_path = os.path.join(temp_dir, 'RENAMED.txt') + os.rename(newfile_path, renamed_path) + repo.index.remove(['NEWFILE.txt']) + repo.index.add(['RENAMED.txt']) + + head_ref = get_safe_head_reference_for_diff(repo) + diff_index = repo.index.diff(head_ref, create_patch=True, R=True) + + for diff in diff_index: + file_path = get_path_by_os(get_diff_file_path(diff, repo=repo)) + assert file_path == renamed_path From b5b259146b3d5ac7ab7775b5d40bfe1409af4fe7 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Thu, 11 Sep 2025 17:11:05 +0200 Subject: [PATCH 198/257] CM-51935 - Fix pre-receive hook for bare repositories (#345) --- .../files_collector/commit_range_documents.py | 45 +++-- .../test_commit_range_documents.py | 181 ++++++++++++++++++ 2 files changed, 208 insertions(+), 18 deletions(-) diff --git a/cycode/cli/files_collector/commit_range_documents.py b/cycode/cli/files_collector/commit_range_documents.py index 572d0cc2..65cf8506 100644 --- a/cycode/cli/files_collector/commit_range_documents.py +++ b/cycode/cli/files_collector/commit_range_documents.py @@ -212,24 +212,33 @@ def parse_pre_receive_input() -> str: def get_diff_file_path(diff: 'Diff', relative: bool = False, repo: Optional['Repo'] = None) -> Optional[str]: - if relative: - # relative to the repository root - return diff.b_path if diff.b_path else diff.a_path - - # Try blob-based paths first (most reliable when available) - if diff.b_blob: - return diff.b_blob.abspath - if diff.a_blob: - return diff.a_blob.abspath - - # Fallback: construct an absolute path from a relative path - # This handles renames and other cases where blobs might be None - if repo and repo.working_tree_dir: - target_path = diff.b_path if diff.b_path else diff.a_path - if target_path: - return os.path.abspath(os.path.join(repo.working_tree_dir, target_path)) - - return None + """Get the file path from a git Diff object. + + Args: + diff: Git Diff object representing the file change + relative: If True, return the path relative to the repository root; + otherwise, return an absolute path IF possible + repo: Optional Git Repo object, used to resolve absolute paths + + Note: + It tries to get the absolute path, falling back to relative paths. `relative` flag forces relative paths. + + One case of relative paths is when the repository is bare and does not have a working tree directory. + """ + # try blob-based paths first (most reliable when available) + blob = diff.b_blob if diff.b_blob else diff.a_blob + if blob: + if relative: + return blob.path + if repo and repo.working_tree_dir: + return blob.abspath + + path = diff.b_path if diff.b_path else diff.a_path # relative path within the repo + if not relative and path and repo and repo.working_tree_dir: + # convert to the absolute path using the repo's working tree directory + path = os.path.join(repo.working_tree_dir, path) + + return path def get_diff_file_content(diff: 'Diff') -> str: diff --git a/tests/cli/files_collector/test_commit_range_documents.py b/tests/cli/files_collector/test_commit_range_documents.py index c51db5ad..c092d4c4 100644 --- a/tests/cli/files_collector/test_commit_range_documents.py +++ b/tests/cli/files_collector/test_commit_range_documents.py @@ -155,3 +155,184 @@ def test_git_mv_pre_commit_scan() -> None: for diff in diff_index: file_path = get_path_by_os(get_diff_file_path(diff, repo=repo)) assert file_path == renamed_path + + +class TestGetDiffFilePath: + """Test the get_diff_file_path function with various diff scenarios.""" + + def test_diff_with_b_blob_and_working_tree(self) -> None: + """Test that blob.abspath is returned when b_blob is available and repo has a working tree.""" + with temporary_git_repository() as (temp_dir, repo): + test_file = os.path.join(temp_dir, 'test_file.py') + with open(test_file, 'w') as f: + f.write("print('original content')") + + repo.index.add(['test_file.py']) + repo.index.commit('Initial commit') + + with open(test_file, 'w') as f: + f.write("print('modified content')") + + repo.index.add(['test_file.py']) + + # Get diff of staged changes + head_ref = get_safe_head_reference_for_diff(repo) + diff_index = repo.index.diff(head_ref) + + assert len(diff_index) == 1 + + result = get_diff_file_path(diff_index[0], repo=repo) + + assert result == test_file + assert os.path.isabs(result) + + def test_diff_with_a_blob_only_and_working_tree(self) -> None: + """Test that a_blob.abspath is used when b_blob is None but a_blob exists.""" + with temporary_git_repository() as (temp_dir, repo): + test_file = os.path.join(temp_dir, 'to_delete.py') + with open(test_file, 'w') as f: + f.write("print('will be deleted')") + + repo.index.add(['to_delete.py']) + repo.index.commit('Initial commit') + + os.remove(test_file) + repo.index.remove(['to_delete.py']) + + # Get diff of staged changes + head_ref = get_safe_head_reference_for_diff(repo) + diff_index = repo.index.diff(head_ref) + + assert len(diff_index) == 1 + + result = get_diff_file_path(diff_index[0], repo=repo) + + assert result == test_file + assert os.path.isabs(result) + + def test_diff_with_b_path_fallback(self) -> None: + """Test that b_path is used with working_tree_dir when blob is not available.""" + with temporary_git_repository() as (temp_dir, repo): + test_file = os.path.join(temp_dir, 'new_file.py') + with open(test_file, 'w') as f: + f.write("print('new file')") + + repo.index.add(['new_file.py']) + + # for new files, there's no a_blob + head_ref = get_safe_head_reference_for_diff(repo) + diff_index = repo.index.diff(head_ref) + diff = diff_index[0] + + assert len(diff_index) == 1 + + result = get_diff_file_path(diff, repo=repo) + assert result == test_file + assert os.path.isabs(result) + + result = get_diff_file_path(diff, relative=True, repo=repo) + assert test_file.endswith(result) + assert not os.path.isabs(result) + + def test_diff_with_a_path_fallback(self) -> None: + """Test that a_path is used when b_path is None.""" + with temporary_git_repository() as (temp_dir, repo): + test_file = os.path.join(temp_dir, 'deleted_file.py') + with open(test_file, 'w') as f: + f.write("print('will be deleted')") + + repo.index.add(['deleted_file.py']) + repo.index.commit('Initial commit') + + # for deleted files, b_path might be None, so a_path should be used + os.remove(test_file) + repo.index.remove(['deleted_file.py']) + + head_ref = get_safe_head_reference_for_diff(repo) + diff_index = repo.index.diff(head_ref) + + assert len(diff_index) == 1 + diff = diff_index[0] + + result = get_diff_file_path(diff, repo=repo) + assert result == test_file + assert os.path.isabs(result) + + result = get_diff_file_path(diff, relative=True, repo=repo) + assert test_file.endswith(result) + assert not os.path.isabs(result) + + def test_diff_without_repo(self) -> None: + """Test behavior when repo is None.""" + with temporary_git_repository() as (temp_dir, repo): + test_file = os.path.join(temp_dir, 'test.py') + with open(test_file, 'w') as f: + f.write("print('test')") + + repo.index.add(['test.py']) + head_ref = get_safe_head_reference_for_diff(repo) + diff_index = repo.index.diff(head_ref) + + assert len(diff_index) == 1 + diff = diff_index[0] + + result = get_diff_file_path(diff, repo=None) + + expected_path = diff.b_path or diff.a_path + assert result == expected_path + assert not os.path.isabs(result) + + def test_diff_with_bare_repository(self) -> None: + """Test behavior when the repository has no working tree directory.""" + with tempfile.TemporaryDirectory() as temp_dir: + bare_repo = Repo.init(temp_dir, bare=True) + + try: + # Create a regular repo to push to the bare repo + with tempfile.TemporaryDirectory() as work_dir: + work_repo = Repo.init(work_dir, b='main') + try: + test_file = os.path.join(work_dir, 'test.py') + with open(test_file, 'w') as f: + f.write("print('test')") + + work_repo.index.add(['test.py']) + work_repo.index.commit('Initial commit') + + work_repo.create_remote('origin', temp_dir) + work_repo.remotes.origin.push('main:main') + + with open(test_file, 'w') as f: + f.write("print('modified')") + work_repo.index.add(['test.py']) + + # Get diff + diff_index = work_repo.index.diff('HEAD') + assert len(diff_index) == 1 + diff = diff_index[0] + + # Test with bare repo (no working_tree_dir) + result = get_diff_file_path(diff, repo=bare_repo) + + # Should return a relative path since bare repo has no working tree + expected_path = diff.b_path or diff.a_path + assert result == expected_path + assert not os.path.isabs(result) + finally: + work_repo.close() + finally: + bare_repo.close() + + def test_diff_with_no_paths(self) -> None: + """Test behavior when the diff has neither a_path nor b_path.""" + with temporary_git_repository() as (temp_dir, repo): + + class MockDiff: + def __init__(self) -> None: + self.a_path = None + self.b_path = None + self.a_blob = None + self.b_blob = None + + result = get_diff_file_path(MockDiff(), repo=repo) + assert result is None From b254122fc6a8023061683cd1f3c477ea0183e7f9 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Fri, 19 Sep 2025 11:46:45 +0200 Subject: [PATCH 199/257] CM-52972 - Add pre-push hook support (#346) --- .pre-commit-hooks.yaml | 21 + README.md | 155 +++++- cycode/cli/apps/scan/__init__.py | 13 +- cycode/cli/apps/scan/pre_push/__init__.py | 0 .../apps/scan/pre_push/pre_push_command.py | 68 +++ .../scan/pre_receive/pre_receive_command.py | 2 +- cycode/cli/consts.py | 9 +- .../files_collector/commit_range_documents.py | 123 +++++ .../user_settings/configuration_manager.py | 44 +- .../test_commit_range_documents.py | 469 ++++++++++++++++++ 10 files changed, 880 insertions(+), 24 deletions(-) create mode 100644 cycode/cli/apps/scan/pre_push/__init__.py create mode 100644 cycode/cli/apps/scan/pre_push/pre_push_command.py diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index c50e4d73..fd2bfbed 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -16,3 +16,24 @@ language_version: python3 entry: cycode args: [ '-o', 'text', '--no-progress-meter', 'scan', '-t', 'sast', 'pre-commit' ] +- id: cycode-pre-push + name: Cycode Secrets pre-push defender + language: python + language_version: python3 + entry: cycode + args: [ '-o', 'text', '--no-progress-meter', 'scan', '-t', 'secret', 'pre-push' ] + stages: [pre-push] +- id: cycode-sca-pre-push + name: Cycode SCA pre-push defender + language: python + language_version: python3 + entry: cycode + args: [ '-o', 'text', '--no-progress-meter', 'scan', '-t', 'sca', 'pre-push' ] + stages: [pre-push] +- id: cycode-sast-pre-push + name: Cycode SAST pre-push defender + language: python + language_version: python3 + entry: cycode + args: [ '-o', 'text', '--no-progress-meter', 'scan', '-t', 'sast', 'pre-push' ] + stages: [pre-push] diff --git a/README.md b/README.md index 06d12822..30f31b9d 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ This guide walks you through both installation and usage. 4. [Commit History Scan](#commit-history-scan) 1. [Commit Range Option (Diff Scanning)](#commit-range-option-diff-scanning) 5. [Pre-Commit Scan](#pre-commit-scan) + 6. [Pre-Push Scan](#pre-push-scan) 2. [Scan Results](#scan-results) 1. [Show/Hide Secrets](#showhide-secrets) 2. [Soft Fail](#soft-fail) @@ -213,13 +214,15 @@ export CYCODE_CLIENT_SECRET={your Cycode Secret Key} ## Install Pre-Commit Hook -Cycode’s pre-commit hook can be set up within your local repository so that the Cycode CLI application will identify any issues with your code automatically before you commit it to your codebase. +Cycode's pre-commit and pre-push hooks can be set up within your local repository so that the Cycode CLI application will identify any issues with your code automatically before you commit or push it to your codebase. > [!NOTE] -> pre-commit hook is not available for IaC scans. +> pre-commit and pre-push hooks are not available for IaC scans. Perform the following steps to install the pre-commit hook: +### Installing Pre-Commit Hook + 1. Install the pre-commit framework (Python 3.9 or higher must be installed): ```bash @@ -233,11 +236,10 @@ Perform the following steps to install the pre-commit hook: ```yaml repos: - repo: https://github.com/cycodehq/cycode-cli - rev: v3.4.2 + rev: v3.5.0 hooks: - id: cycode - stages: - - pre-commit + stages: [pre-commit] ``` 4. Modify the created file for your specific needs. Use hook ID `cycode` to enable scan for Secrets. Use hook ID `cycode-sca` to enable SCA scan. Use hook ID `cycode-sast` to enable SAST scan. If you want to enable all scanning types, use this configuration: @@ -245,17 +247,14 @@ Perform the following steps to install the pre-commit hook: ```yaml repos: - repo: https://github.com/cycodehq/cycode-cli - rev: v3.4.2 + rev: v3.5.0 hooks: - id: cycode - stages: - - pre-commit + stages: [pre-commit] - id: cycode-sca - stages: - - pre-commit + stages: [pre-commit] - id: cycode-sast - stages: - - pre-commit + stages: [pre-commit] ``` 5. Install Cycode’s hook: @@ -278,6 +277,37 @@ Perform the following steps to install the pre-commit hook: > Trigger happens on `git commit` command. > Hook triggers only on the files that are staged for commit. +### Installing Pre-Push Hook + +To install the pre-push hook in addition to or instead of the pre-commit hook: + +1. Add the pre-push hooks to your `.pre-commit-config.yaml` file: + + ```yaml + repos: + - repo: https://github.com/cycodehq/cycode-cli + rev: v3.5.0 + hooks: + - id: cycode-pre-push + stages: [pre-push] + ``` + +2. Install the pre-push hook: + + ```bash + pre-commit install --hook-type pre-push + ``` + +3. For both pre-commit and pre-push hooks, use: + + ```bash + pre-commit install + pre-commit install --hook-type pre-push + ``` + +> [!NOTE] +> Pre-push hooks trigger on `git push` command and scan only the commits about to be pushed. + # Cycode CLI Commands The following are the options and commands available with the Cycode CLI application: @@ -786,6 +816,107 @@ After installing the pre-commit hook, you may occasionally wish to skip scanning SKIP=cycode git commit -m ` ``` +### Pre-Push Scan + +A pre-push scan automatically identifies any issues before you push changes to the remote repository. This hook runs on the client side and scans only the commits that are about to be pushed, making it efficient for catching issues before they reach the remote repository. + +> [!NOTE] +> Pre-push hook is not available for IaC scans. + +The pre-push hook integrates with the pre-commit framework and can be configured to run before any `git push` operation. + +#### Installing Pre-Push Hook + +To set up the pre-push hook using the pre-commit framework: + +1. Install the pre-commit framework (if not already installed): + + ```bash + pip3 install pre-commit + ``` + +2. Create or update your `.pre-commit-config.yaml` file to include the pre-push hooks: + + ```yaml + repos: + - repo: https://github.com/cycodehq/cycode-cli + rev: v3.5.0 + hooks: + - id: cycode-pre-push + stages: [pre-push] + ``` + +3. For multiple scan types, use this configuration: + + ```yaml + repos: + - repo: https://github.com/cycodehq/cycode-cli + rev: v3.5.0 + hooks: + - id: cycode-pre-push # Secrets scan + stages: [pre-push] + - id: cycode-sca-pre-push # SCA scan + stages: [pre-push] + - id: cycode-sast-pre-push # SAST scan + stages: [pre-push] + ``` + +4. Install the pre-push hook: + + ```bash + pre-commit install --hook-type pre-push + ``` + + A successful installation will result in the message: `Pre-push installed at .git/hooks/pre-push`. + +5. Keep the pre-push hook up to date: + + ```bash + pre-commit autoupdate + ``` + +#### How Pre-Push Scanning Works + +The pre-push hook: +- Receives information about what commits are being pushed +- Calculates the appropriate commit range to scan +- For new branches: scans all commits from the merge base with the default branch +- For existing branches: scans only the new commits since the last push +- Runs the same comprehensive scanning as other Cycode scan modes + +#### Smart Default Branch Detection + +The pre-push hook intelligently detects the default branch for merge base calculation using this priority order: + +1. **Environment Variable**: `CYCODE_DEFAULT_BRANCH` - allows manual override +2. **Git Remote HEAD**: Uses `git symbolic-ref refs/remotes/origin/HEAD` to detect the actual remote default branch +3. **Git Remote Info**: Falls back to `git remote show origin` if symbolic-ref fails +4. **Hardcoded Fallbacks**: Uses common default branch names (origin/main, origin/master, main, master) + +**Setting a Custom Default Branch:** +```bash +export CYCODE_DEFAULT_BRANCH=origin/develop +``` + +This smart detection ensures the pre-push hook works correctly regardless of whether your repository uses `main`, `master`, `develop`, or any other default branch name. + +#### Skipping Pre-Push Scans + +To skip the pre-push scan for a specific push operation, use: + +```bash +SKIP=cycode-pre-push git push +``` + +Or to skip all pre-push hooks: + +```bash +git push --no-verify +``` + +> [!TIP] +> The pre-push hook is triggered on `git push` command and scans only the commits that are about to be pushed, making it more efficient than scanning the entire repository. + ## Scan Results Each scan will complete with a message stating if any issues were found or not. diff --git a/cycode/cli/apps/scan/__init__.py b/cycode/cli/apps/scan/__init__.py index 07611c58..629c3b8f 100644 --- a/cycode/cli/apps/scan/__init__.py +++ b/cycode/cli/apps/scan/__init__.py @@ -3,12 +3,15 @@ from cycode.cli.apps.scan.commit_history.commit_history_command import commit_history_command from cycode.cli.apps.scan.path.path_command import path_command from cycode.cli.apps.scan.pre_commit.pre_commit_command import pre_commit_command +from cycode.cli.apps.scan.pre_push.pre_push_command import pre_push_command from cycode.cli.apps.scan.pre_receive.pre_receive_command import pre_receive_command from cycode.cli.apps.scan.repository.repository_command import repository_command from cycode.cli.apps.scan.scan_command import scan_command, scan_command_result_callback app = typer.Typer(name='scan', no_args_is_help=True) +_AUTOMATION_COMMANDS_RICH_HELP_PANEL = 'Automation commands' + _scan_command_docs = 'https://github.com/cycodehq/cycode-cli/blob/main/README.md#scan-command' _scan_command_epilog = f'[bold]Documentation:[/] [link={_scan_command_docs}]{_scan_command_docs}[/link]' @@ -26,16 +29,22 @@ app.command( name='pre-commit', short_help='Use this command in pre-commit hook to scan any content that was not committed yet.', - rich_help_panel='Automation commands', + rich_help_panel=_AUTOMATION_COMMANDS_RICH_HELP_PANEL, )(pre_commit_command) +app.command( + name='pre-push', + short_help='Use this command in pre-push hook to scan commits before pushing them to the remote repository.', + rich_help_panel=_AUTOMATION_COMMANDS_RICH_HELP_PANEL, +)(pre_push_command) app.command( name='pre-receive', short_help='Use this command in pre-receive hook ' 'to scan commits on the server side before pushing them to the repository.', - rich_help_panel='Automation commands', + rich_help_panel=_AUTOMATION_COMMANDS_RICH_HELP_PANEL, )(pre_receive_command) # backward compatibility app.command(hidden=True, name='commit_history')(commit_history_command) app.command(hidden=True, name='pre_commit')(pre_commit_command) +app.command(hidden=True, name='pre_push')(pre_push_command) app.command(hidden=True, name='pre_receive')(pre_receive_command) diff --git a/cycode/cli/apps/scan/pre_push/__init__.py b/cycode/cli/apps/scan/pre_push/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/apps/scan/pre_push/pre_push_command.py b/cycode/cli/apps/scan/pre_push/pre_push_command.py new file mode 100644 index 00000000..868ab62e --- /dev/null +++ b/cycode/cli/apps/scan/pre_push/pre_push_command.py @@ -0,0 +1,68 @@ +import logging +import os +from typing import Annotated, Optional + +import typer + +from cycode.cli import consts +from cycode.cli.apps.scan.commit_range_scanner import ( + is_verbose_mode_requested_in_pre_receive_scan, + scan_commit_range, + should_skip_pre_receive_scan, +) +from cycode.cli.config import configuration_manager +from cycode.cli.console import console +from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception +from cycode.cli.files_collector.commit_range_documents import ( + calculate_pre_push_commit_range, + parse_pre_push_input, +) +from cycode.cli.logger import logger +from cycode.cli.utils import scan_utils +from cycode.cli.utils.sentry import add_breadcrumb +from cycode.cli.utils.task_timer import TimeoutAfter +from cycode.logger import set_logging_level + + +def pre_push_command( + ctx: typer.Context, + _: Annotated[Optional[list[str]], typer.Argument(help='Ignored arguments', hidden=True)] = None, +) -> None: + try: + add_breadcrumb('pre_push') + + if should_skip_pre_receive_scan(): + logger.info( + 'A scan has been skipped as per your request. ' + 'Please note that this may leave your system vulnerable to secrets that have not been detected.' + ) + return + + if is_verbose_mode_requested_in_pre_receive_scan(): + ctx.obj['verbose'] = True + set_logging_level(logging.DEBUG) + logger.debug('Verbose mode enabled: all log levels will be displayed.') + + command_scan_type = ctx.info_name + timeout = configuration_manager.get_pre_push_command_timeout(command_scan_type) + with TimeoutAfter(timeout): + push_update_details = parse_pre_push_input() + commit_range = calculate_pre_push_commit_range(push_update_details) + if not commit_range: + logger.info( + 'No new commits found for pushed branch, %s', + {'push_update_details': push_update_details}, + ) + return + + scan_commit_range( + ctx=ctx, + repo_path=os.getcwd(), + commit_range=commit_range, + max_commits_count=configuration_manager.get_pre_push_max_commits_to_scan_count(command_scan_type), + ) + + if scan_utils.is_scan_failed(ctx): + console.print(consts.PRE_RECEIVE_AND_PUSH_REMEDIATION_MESSAGE) + except Exception as e: + handle_scan_exception(ctx, e) diff --git a/cycode/cli/apps/scan/pre_receive/pre_receive_command.py b/cycode/cli/apps/scan/pre_receive/pre_receive_command.py index ef30ee8f..3b85dc9e 100644 --- a/cycode/cli/apps/scan/pre_receive/pre_receive_command.py +++ b/cycode/cli/apps/scan/pre_receive/pre_receive_command.py @@ -63,6 +63,6 @@ def pre_receive_command( ) if scan_utils.is_scan_failed(ctx): - console.print(consts.PRE_RECEIVE_REMEDIATION_MESSAGE) + console.print(consts.PRE_RECEIVE_AND_PUSH_REMEDIATION_MESSAGE) except Exception as e: handle_scan_exception(ctx, e) diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 19347b1d..7384e33e 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -240,7 +240,14 @@ DEFAULT_PRE_RECEIVE_MAX_COMMITS_TO_SCAN_COUNT = 50 PRE_RECEIVE_COMMAND_TIMEOUT_ENV_VAR_NAME = 'PRE_RECEIVE_COMMAND_TIMEOUT' DEFAULT_PRE_RECEIVE_COMMAND_TIMEOUT_IN_SECONDS = 60 -PRE_RECEIVE_REMEDIATION_MESSAGE = """ +# pre push scan +PRE_PUSH_MAX_COMMITS_TO_SCAN_COUNT_ENV_VAR_NAME = 'PRE_PUSH_MAX_COMMITS_TO_SCAN_COUNT' +DEFAULT_PRE_PUSH_MAX_COMMITS_TO_SCAN_COUNT = 50 +PRE_PUSH_COMMAND_TIMEOUT_ENV_VAR_NAME = 'PRE_PUSH_COMMAND_TIMEOUT' +DEFAULT_PRE_PUSH_COMMAND_TIMEOUT_IN_SECONDS = 60 +CYCODE_DEFAULT_BRANCH_ENV_VAR_NAME = 'CYCODE_DEFAULT_BRANCH' +# pre push and pre receive common +PRE_RECEIVE_AND_PUSH_REMEDIATION_MESSAGE = """ Cycode Secrets Push Protection ------------------------------------------------------------------------------ Resolve the following secrets by rewriting your local commit history before pushing again. diff --git a/cycode/cli/files_collector/commit_range_documents.py b/cycode/cli/files_collector/commit_range_documents.py index 65cf8506..3d527498 100644 --- a/cycode/cli/files_collector/commit_range_documents.py +++ b/cycode/cli/files_collector/commit_range_documents.py @@ -211,6 +211,129 @@ def parse_pre_receive_input() -> str: return pre_receive_input.splitlines()[0] +def parse_pre_push_input() -> str: + """Parse input to pre-push hook details. + + Example input: + local_ref local_object_name remote_ref remote_object_name + --------------------------------------------------------- + refs/heads/main 9cf90954ef26e7c58284f8ebf7dcd0fcf711152a refs/heads/main 973a96d3e925b65941f7c47fa16129f1577d499f + refs/heads/feature-branch 3378e52dcfa47fb11ce3a4a520bea5f85d5d0bf3 refs/heads/feature-branch 59564ef68745bca38c42fc57a7822efd519a6bd9 + + :return: First, push update details (input's first line) + """ # noqa: E501 + pre_push_input = sys.stdin.read().strip() + if not pre_push_input: + raise ValueError( + 'Pre push input was not found. Make sure that you are using this command only in pre-push hook' + ) + + # each line represents a branch push request, handle the first one only + return pre_push_input.splitlines()[0] + + +def _get_default_branches_for_merge_base(repo: 'Repo') -> list[str]: + """Get a list of default branches to try for merge base calculation. + + Priority order: + 1. Environment variable CYCODE_DEFAULT_BRANCH + 2. Git remote HEAD (git symbolic-ref refs/remotes/origin/HEAD) + 3. Fallback to common default branch names + + Args: + repo: Git repository object + + Returns: + List of branch names to try for merge base calculation + """ + default_branches = [] + + # 1. Check environment variable first + env_default_branch = os.getenv(consts.CYCODE_DEFAULT_BRANCH_ENV_VAR_NAME) + if env_default_branch: + logger.debug('Using default branch from environment variable: %s', env_default_branch) + default_branches.append(env_default_branch) + + # 2. Try to get the actual default branch from remote HEAD + try: + remote_head = repo.git.symbolic_ref('refs/remotes/origin/HEAD') + # symbolic-ref returns something like "refs/remotes/origin/main" + if remote_head.startswith('refs/remotes/origin/'): + default_branch = remote_head.replace('refs/remotes/origin/', '') + logger.debug('Found remote default branch: %s', default_branch) + # Add both the remote tracking branch and local branch variants + default_branches.extend([f'origin/{default_branch}', default_branch]) + except Exception as e: + logger.debug('Failed to get remote HEAD via symbolic-ref: %s', exc_info=e) + + # Try an alternative method: git remote show origin + try: + remote_info = repo.git.remote('show', 'origin') + for line in remote_info.splitlines(): + if 'HEAD branch:' in line: + default_branch = line.split('HEAD branch:')[1].strip() + logger.debug('Found default branch via remote show: %s', default_branch) + default_branches.extend([f'origin/{default_branch}', default_branch]) + break + except Exception as e2: + logger.debug('Failed to get remote info via remote show: %s', exc_info=e2) + + # 3. Add fallback branches (avoiding duplicates) + fallback_branches = ['origin/main', 'origin/master', 'main', 'master'] + for branch in fallback_branches: + if branch not in default_branches: + default_branches.append(branch) + + logger.debug('Default branches to try: %s', default_branches) + return default_branches + + +def calculate_pre_push_commit_range(push_update_details: str) -> Optional[str]: + """Calculate the commit range for pre-push hook scanning. + + Args: + push_update_details: String in format "local_ref local_object_name remote_ref remote_object_name" + + Returns: + Commit range string for scanning, or None if no scanning is needed + + Environment Variables: + CYCODE_DEFAULT_BRANCH: Override the default branch for merge base calculation + """ + local_ref, local_object_name, remote_ref, remote_object_name = push_update_details.split() + + if remote_object_name == consts.EMPTY_COMMIT_SHA: + try: + repo = git_proxy.get_repo(os.getcwd()) + default_branches = _get_default_branches_for_merge_base(repo) + + merge_base = None + for default_branch in default_branches: + try: + merge_base = repo.git.merge_base(local_object_name, default_branch) + logger.debug('Found merge base %s with branch %s', merge_base, default_branch) + break + except Exception as e: + logger.debug('Failed to find merge base with %s: %s', default_branch, exc_info=e) + continue + + if merge_base: + return f'{merge_base}..{local_object_name}' + + logger.debug('Failed to find merge base with any default branch') + return '--all' + except Exception as e: + logger.debug('Failed to get repo for pre-push commit range calculation: %s', exc_info=e) + return '--all' + + # If deleting a branch (local_object_name is all zeros), no need to scan + if local_object_name == consts.EMPTY_COMMIT_SHA: + return None + + # For updates to existing branches, scan from remote to local + return f'{remote_object_name}..{local_object_name}' + + def get_diff_file_path(diff: 'Diff', relative: bool = False, repo: Optional['Repo'] = None) -> Optional[str]: """Get the file path from a git Diff object. diff --git a/cycode/cli/user_settings/configuration_manager.py b/cycode/cli/user_settings/configuration_manager.py index 85fd7eac..689ec0d5 100644 --- a/cycode/cli/user_settings/configuration_manager.py +++ b/cycode/cli/user_settings/configuration_manager.py @@ -135,10 +135,10 @@ def get_sca_pre_commit_timeout_in_seconds(self) -> int: ) ) - def get_pre_receive_max_commits_to_scan_count(self, command_scan_type: str) -> int: - max_commits = self._get_value_from_environment_variables( - consts.PRE_RECEIVE_MAX_COMMITS_TO_SCAN_COUNT_ENV_VAR_NAME - ) + def _get_git_hook_max_commits_to_scan_count( + self, command_scan_type: str, env_var_name: str, default_count: int + ) -> int: + max_commits = self._get_value_from_environment_variables(env_var_name) if max_commits is not None: return int(max_commits) @@ -150,10 +150,24 @@ def get_pre_receive_max_commits_to_scan_count(self, command_scan_type: str) -> i if max_commits is not None: return max_commits - return consts.DEFAULT_PRE_RECEIVE_MAX_COMMITS_TO_SCAN_COUNT + return default_count - def get_pre_receive_command_timeout(self, command_scan_type: str) -> int: - command_timeout = self._get_value_from_environment_variables(consts.PRE_RECEIVE_COMMAND_TIMEOUT_ENV_VAR_NAME) + def get_pre_receive_max_commits_to_scan_count(self, command_scan_type: str) -> int: + return self._get_git_hook_max_commits_to_scan_count( + command_scan_type, + consts.PRE_RECEIVE_MAX_COMMITS_TO_SCAN_COUNT_ENV_VAR_NAME, + consts.DEFAULT_PRE_RECEIVE_MAX_COMMITS_TO_SCAN_COUNT, + ) + + def get_pre_push_max_commits_to_scan_count(self, command_scan_type: str) -> int: + return self._get_git_hook_max_commits_to_scan_count( + command_scan_type, + consts.PRE_PUSH_MAX_COMMITS_TO_SCAN_COUNT_ENV_VAR_NAME, + consts.DEFAULT_PRE_PUSH_MAX_COMMITS_TO_SCAN_COUNT, + ) + + def _get_git_hook_command_timeout(self, command_scan_type: str, env_var_name: str, default_timeout: int) -> int: + command_timeout = self._get_value_from_environment_variables(env_var_name) if command_timeout is not None: return int(command_timeout) @@ -165,7 +179,21 @@ def get_pre_receive_command_timeout(self, command_scan_type: str) -> int: if command_timeout is not None: return command_timeout - return consts.DEFAULT_PRE_RECEIVE_COMMAND_TIMEOUT_IN_SECONDS + return default_timeout + + def get_pre_receive_command_timeout(self, command_scan_type: str) -> int: + return self._get_git_hook_command_timeout( + command_scan_type, + consts.PRE_RECEIVE_COMMAND_TIMEOUT_ENV_VAR_NAME, + consts.DEFAULT_PRE_RECEIVE_COMMAND_TIMEOUT_IN_SECONDS, + ) + + def get_pre_push_command_timeout(self, command_scan_type: str) -> int: + return self._get_git_hook_command_timeout( + command_scan_type, + consts.PRE_PUSH_COMMAND_TIMEOUT_ENV_VAR_NAME, + consts.DEFAULT_PRE_PUSH_COMMAND_TIMEOUT_IN_SECONDS, + ) def get_should_exclude_detections_in_deleted_lines(self, command_scan_type: str) -> bool: exclude_detections_in_deleted_lines = self._get_value_from_environment_variables( diff --git a/tests/cli/files_collector/test_commit_range_documents.py b/tests/cli/files_collector/test_commit_range_documents.py index c092d4c4..568b1bec 100644 --- a/tests/cli/files_collector/test_commit_range_documents.py +++ b/tests/cli/files_collector/test_commit_range_documents.py @@ -2,13 +2,19 @@ import tempfile from collections.abc import Generator from contextlib import contextmanager +from io import StringIO +from unittest.mock import Mock, patch +import pytest from git import Repo from cycode.cli import consts from cycode.cli.files_collector.commit_range_documents import ( + _get_default_branches_for_merge_base, + calculate_pre_push_commit_range, get_diff_file_path, get_safe_head_reference_for_diff, + parse_pre_push_input, ) from cycode.cli.utils.path_utils import get_path_by_os @@ -336,3 +342,466 @@ def __init__(self) -> None: result = get_diff_file_path(MockDiff(), repo=repo) assert result is None + + +class TestParsePrePushInput: + """Test the parse_pre_push_input function with various pre-push hook input scenarios.""" + + def test_parse_single_push_input(self) -> None: + """Test parsing a single branch push input.""" + pre_push_input = 'refs/heads/main 1234567890abcdef refs/heads/main 0987654321fedcba' + + with patch('sys.stdin', StringIO(pre_push_input)): + result = parse_pre_push_input() + assert result == 'refs/heads/main 1234567890abcdef refs/heads/main 0987654321fedcba' + + def test_parse_multiple_push_input_returns_first_line(self) -> None: + """Test parsing multiple branch push input returns only the first line.""" + pre_push_input = """refs/heads/main 1234567890abcdef refs/heads/main 0987654321fedcba +refs/heads/feature 1111111111111111 refs/heads/feature 2222222222222222""" + + with patch('sys.stdin', StringIO(pre_push_input)): + result = parse_pre_push_input() + assert result == 'refs/heads/main 1234567890abcdef refs/heads/main 0987654321fedcba' + + def test_parse_new_branch_push_input(self) -> None: + """Test parsing input for pushing a new branch (remote object name is all zeros).""" + pre_push_input = f'refs/heads/feature 1234567890abcdef refs/heads/feature {consts.EMPTY_COMMIT_SHA}' + + with patch('sys.stdin', StringIO(pre_push_input)): + result = parse_pre_push_input() + assert result == pre_push_input + + def test_parse_branch_deletion_input(self) -> None: + """Test parsing input for deleting a branch (local object name is all zeros).""" + pre_push_input = f'refs/heads/feature {consts.EMPTY_COMMIT_SHA} refs/heads/feature 1234567890abcdef' + + with patch('sys.stdin', StringIO(pre_push_input)): + result = parse_pre_push_input() + assert result == pre_push_input + + def test_parse_empty_input_raises_error(self) -> None: + """Test that empty input raises ValueError.""" + with patch('sys.stdin', StringIO('')), pytest.raises(ValueError, match='Pre push input was not found'): + parse_pre_push_input() + + def test_parse_whitespace_only_input_raises_error(self) -> None: + """Test that whitespace-only input raises ValueError.""" + with patch('sys.stdin', StringIO(' \n\t ')), pytest.raises(ValueError, match='Pre push input was not found'): + parse_pre_push_input() + + +class TestGetDefaultBranchesForMergeBase: + """Test the _get_default_branches_for_merge_base function with various scenarios.""" + + def test_environment_variable_override(self) -> None: + """Test that the environment variable takes precedence.""" + with ( + temporary_git_repository() as (temp_dir, repo), + patch.dict(os.environ, {consts.CYCODE_DEFAULT_BRANCH_ENV_VAR_NAME: 'custom-main'}), + ): + branches = _get_default_branches_for_merge_base(repo) + assert branches[0] == 'custom-main' + assert 'origin/main' in branches # Fallbacks should still be included + + def test_git_symbolic_ref_success(self) -> None: + """Test getting default branch via git symbolic-ref.""" + with temporary_git_repository() as (temp_dir, repo): + # Create a mock repo with a git interface that returns origin/main + mock_repo = Mock() + mock_repo.git.symbolic_ref.return_value = 'refs/remotes/origin/main' + + branches = _get_default_branches_for_merge_base(mock_repo) + assert 'origin/main' in branches + assert 'main' in branches + + def test_git_symbolic_ref_with_master(self) -> None: + """Test getting default branch via git symbolic-ref when it's master.""" + with temporary_git_repository() as (temp_dir, repo): + # Create a mock repo with a git interface that returns origin/master + mock_repo = Mock() + mock_repo.git.symbolic_ref.return_value = 'refs/remotes/origin/master' + + branches = _get_default_branches_for_merge_base(mock_repo) + assert 'origin/master' in branches + assert 'master' in branches + + def test_git_remote_show_fallback(self) -> None: + """Test fallback to git remote show when symbolic-ref fails.""" + with temporary_git_repository() as (temp_dir, repo): + # Create a mock repo where symbolic-ref fails but the remote show succeeds + mock_repo = Mock() + mock_repo.git.symbolic_ref.side_effect = Exception('symbolic-ref failed') + remote_output = """* remote origin + Fetch URL: https://github.com/user/repo.git + Push URL: https://github.com/user/repo.git + HEAD branch: develop + Remote branches: + develop tracked + main tracked""" + mock_repo.git.remote.return_value = remote_output + + branches = _get_default_branches_for_merge_base(mock_repo) + assert 'origin/develop' in branches + assert 'develop' in branches + + def test_both_git_methods_fail_fallback_to_hardcoded(self) -> None: + """Test fallback to hardcoded branches when both Git methods fail.""" + with temporary_git_repository() as (temp_dir, repo): + # Create a mock repo where both Git methods fail + mock_repo = Mock() + mock_repo.git.symbolic_ref.side_effect = Exception('symbolic-ref failed') + mock_repo.git.remote.side_effect = Exception('remote show failed') + + branches = _get_default_branches_for_merge_base(mock_repo) + # Should contain fallback branches + assert 'origin/main' in branches + assert 'origin/master' in branches + assert 'main' in branches + assert 'master' in branches + + def test_no_duplicates_in_branch_list(self) -> None: + """Test that duplicate branches are not added to the list.""" + with temporary_git_repository() as (temp_dir, repo): + # Create a mock repo that returns main (which is also in fallback list) + mock_repo = Mock() + mock_repo.git.symbolic_ref.return_value = 'refs/remotes/origin/main' + + branches = _get_default_branches_for_merge_base(mock_repo) + # Count occurrences of origin/main - should be exactly 1 + assert branches.count('origin/main') == 1 + assert branches.count('main') == 1 + + def test_env_var_plus_git_detection(self) -> None: + """Test combination of environment variable and git detection.""" + with temporary_git_repository() as (temp_dir, repo): + mock_repo = Mock() + mock_repo.git.symbolic_ref.return_value = 'refs/remotes/origin/develop' + + with patch.dict(os.environ, {consts.CYCODE_DEFAULT_BRANCH_ENV_VAR_NAME: 'origin/custom'}): + branches = _get_default_branches_for_merge_base(mock_repo) + # Env var should be first + assert branches[0] == 'origin/custom' + # Git detected branches should also be present + assert 'origin/develop' in branches + assert 'develop' in branches + + def test_malformed_symbolic_ref_response(self) -> None: + """Test handling of malformed symbolic-ref response.""" + with temporary_git_repository() as (temp_dir, repo): + # Create a mock repo that returns a malformed response + mock_repo = Mock() + mock_repo.git.symbolic_ref.return_value = 'malformed-response' + + branches = _get_default_branches_for_merge_base(mock_repo) + # Should fall back to hardcoded branches + assert 'origin/main' in branches + assert 'origin/master' in branches + + +class TestCalculatePrePushCommitRange: + """Test the calculate_pre_push_commit_range function with various Git repository scenarios.""" + + def test_calculate_range_for_existing_branch_update(self) -> None: + """Test calculating commit range for updating an existing branch.""" + push_details = 'refs/heads/main 1234567890abcdef refs/heads/main 0987654321fedcba' + + result = calculate_pre_push_commit_range(push_details) + assert result == '0987654321fedcba..1234567890abcdef' + + def test_calculate_range_for_branch_deletion_returns_none(self) -> None: + """Test that branch deletion returns None (no scanning needed).""" + push_details = f'refs/heads/feature {consts.EMPTY_COMMIT_SHA} refs/heads/feature 1234567890abcdef' + + result = calculate_pre_push_commit_range(push_details) + assert result is None + + def test_calculate_range_for_new_branch_with_merge_base(self) -> None: + """Test calculating commit range for a new branch when merge base is found.""" + with temporary_git_repository() as (temp_dir, repo): + # Create initial commit on main + test_file = os.path.join(temp_dir, 'main.py') + with open(test_file, 'w') as f: + f.write("print('main')") + + repo.index.add(['main.py']) + main_commit = repo.index.commit('Initial commit on main') + + # Create and switch to a feature branch + feature_branch = repo.create_head('feature') + feature_branch.checkout() + + # Add commits to a feature branch + feature_file = os.path.join(temp_dir, 'feature.py') + with open(feature_file, 'w') as f: + f.write("print('feature')") + + repo.index.add(['feature.py']) + feature_commit = repo.index.commit('Add feature') + + # Switch back to master to simulate we're pushing a feature branch + repo.heads.master.checkout() + + # Test new branch push + push_details = f'refs/heads/feature {feature_commit.hexsha} refs/heads/feature {consts.EMPTY_COMMIT_SHA}' + + with patch('os.getcwd', return_value=temp_dir): + result = calculate_pre_push_commit_range(push_details) + assert result == f'{main_commit.hexsha}..{feature_commit.hexsha}' + + def test_calculate_range_for_new_branch_no_merge_base_fallback_to_all(self) -> None: + """Test that when no merge base is found, it falls back to scanning all commits.""" + with temporary_git_repository() as (temp_dir, repo): + # Create a single commit + test_file = os.path.join(temp_dir, 'test.py') + with open(test_file, 'w') as f: + f.write("print('test')") + + repo.index.add(['test.py']) + commit = repo.index.commit('Initial commit') + + # Test a new branch push with no default branch available + push_details = f'refs/heads/orphan {commit.hexsha} refs/heads/orphan {consts.EMPTY_COMMIT_SHA}' + + # Create a mock repo with a git interface that always raises exceptions for merge_base + mock_repo = Mock() + mock_git = Mock() + mock_git.merge_base.side_effect = Exception('No merge base found') + mock_repo.git = mock_git + + with ( + patch('os.getcwd', return_value=temp_dir), + patch('cycode.cli.files_collector.commit_range_documents.git_proxy.get_repo', return_value=mock_repo), + ): + result = calculate_pre_push_commit_range(push_details) + # Should fallback to --all when no merge base is found + assert result == '--all' + + def test_calculate_range_with_origin_main_as_merge_base(self) -> None: + """Test calculating commit range using origin/main as merge base.""" + with temporary_git_repository() as (temp_dir, repo): + # Create the main branch with commits + main_file = os.path.join(temp_dir, 'main.py') + with open(main_file, 'w') as f: + f.write("print('main')") + + repo.index.add(['main.py']) + main_commit = repo.index.commit('Main commit') + + # Create origin/main reference (simulating a remote) + repo.create_head('origin/main', main_commit) + + # Create feature branch from main + feature_branch = repo.create_head('feature', main_commit) + feature_branch.checkout() + + # Add feature commits + feature_file = os.path.join(temp_dir, 'feature.py') + with open(feature_file, 'w') as f: + f.write("print('feature')") + + repo.index.add(['feature.py']) + feature_commit = repo.index.commit('Feature commit') + + # Test new branch push + push_details = f'refs/heads/feature {feature_commit.hexsha} refs/heads/feature {consts.EMPTY_COMMIT_SHA}' + + with patch('os.getcwd', return_value=temp_dir): + result = calculate_pre_push_commit_range(push_details) + assert result == f'{main_commit.hexsha}..{feature_commit.hexsha}' + + def test_calculate_range_with_environment_variable_override(self) -> None: + """Test that environment variable override works for commit range calculation.""" + with temporary_git_repository() as (temp_dir, repo): + # Create custom default branch + custom_file = os.path.join(temp_dir, 'custom.py') + with open(custom_file, 'w') as f: + f.write("print('custom')") + + repo.index.add(['custom.py']) + custom_commit = repo.index.commit('Custom branch commit') + + # Create a custom branch + repo.create_head('custom-main', custom_commit) + + # Create a feature branch from custom + feature_branch = repo.create_head('feature', custom_commit) + feature_branch.checkout() + + # Add feature commits + feature_file = os.path.join(temp_dir, 'feature.py') + with open(feature_file, 'w') as f: + f.write("print('feature')") + + repo.index.add(['feature.py']) + feature_commit = repo.index.commit('Feature commit') + + # Test new branch push with custom default branch + push_details = f'refs/heads/feature {feature_commit.hexsha} refs/heads/feature {consts.EMPTY_COMMIT_SHA}' + + with ( + patch('os.getcwd', return_value=temp_dir), + patch.dict(os.environ, {consts.CYCODE_DEFAULT_BRANCH_ENV_VAR_NAME: 'custom-main'}), + ): + result = calculate_pre_push_commit_range(push_details) + assert result == f'{custom_commit.hexsha}..{feature_commit.hexsha}' + + def test_calculate_range_with_git_symbolic_ref_detection(self) -> None: + """Test commit range calculation with Git symbolic-ref detection.""" + with temporary_git_repository() as (temp_dir, repo): + # Create develop branch and commits + develop_file = os.path.join(temp_dir, 'develop.py') + with open(develop_file, 'w') as f: + f.write("print('develop')") + + repo.index.add(['develop.py']) + develop_commit = repo.index.commit('Develop commit') + + # Create origin/develop reference + repo.create_head('origin/develop', develop_commit) + repo.create_head('develop', develop_commit) + + # Create a feature branch + feature_branch = repo.create_head('feature', develop_commit) + feature_branch.checkout() + + # Add feature commits + feature_file = os.path.join(temp_dir, 'feature.py') + with open(feature_file, 'w') as f: + f.write("print('feature')") + + repo.index.add(['feature.py']) + feature_commit = repo.index.commit('Feature commit') + + # Test a new branch push with mocked default branch detection + push_details = f'refs/heads/feature {feature_commit.hexsha} refs/heads/feature {consts.EMPTY_COMMIT_SHA}' + + # # Mock the default branch detection to return origin/develop first + with ( + patch('os.getcwd', return_value=temp_dir), + patch( + 'cycode.cli.files_collector.commit_range_documents._get_default_branches_for_merge_base' + ) as mock_get_branches, + ): + mock_get_branches.return_value = [ + 'origin/develop', + 'develop', + 'origin/main', + 'main', + 'origin/master', + 'master', + ] + with patch('cycode.cli.files_collector.commit_range_documents.git_proxy.get_repo', return_value=repo): + result = calculate_pre_push_commit_range(push_details) + assert result == f'{develop_commit.hexsha}..{feature_commit.hexsha}' + + def test_calculate_range_with_origin_master_as_merge_base(self) -> None: + """Test calculating commit range using origin/master as a merge base.""" + with temporary_git_repository() as (temp_dir, repo): + # Create a main branch with commits + master_file = os.path.join(temp_dir, 'master.py') + with open(master_file, 'w') as f: + f.write("print('master')") + + repo.index.add(['master.py']) + master_commit = repo.index.commit('Master commit') + + # Create origin/master (master branch already exists by default) + repo.create_head('origin/master', master_commit) + + # Create a feature branch + feature_branch = repo.create_head('feature', master_commit) + feature_branch.checkout() + + # Add feature commits + feature_file = os.path.join(temp_dir, 'feature.py') + with open(feature_file, 'w') as f: + f.write("print('feature')") + + repo.index.add(['feature.py']) + feature_commit = repo.index.commit('Feature commit') + + # Test new branch push + push_details = f'refs/heads/feature {feature_commit.hexsha} refs/heads/feature {consts.EMPTY_COMMIT_SHA}' + + with patch('os.getcwd', return_value=temp_dir): + result = calculate_pre_push_commit_range(push_details) + assert result == f'{master_commit.hexsha}..{feature_commit.hexsha}' + + def test_calculate_range_exception_handling_fallback_to_all(self) -> None: + """Test that exceptions during Git repository access fall back to --all.""" + push_details = f'refs/heads/feature 1234567890abcdef refs/heads/feature {consts.EMPTY_COMMIT_SHA}' + + # Mock git_proxy.get_repo to raise an exception and capture the exception handling + with patch('cycode.cli.files_collector.commit_range_documents.git_proxy.get_repo') as mock_get_repo: + mock_get_repo.side_effect = Exception('Test exception') + result = calculate_pre_push_commit_range(push_details) + assert result == '--all' + + def test_calculate_range_parsing_push_details(self) -> None: + """Test that push details are correctly parsed into components.""" + # Test with standard format + push_details = 'refs/heads/feature abc123def456 refs/heads/feature 789xyz456abc' + + result = calculate_pre_push_commit_range(push_details) + assert result == '789xyz456abc..abc123def456' + + def test_calculate_range_with_tags(self) -> None: + """Test calculating commit range when pushing tags.""" + push_details = f'refs/tags/v1.0.0 1234567890abcdef refs/tags/v1.0.0 {consts.EMPTY_COMMIT_SHA}' + + with temporary_git_repository() as (temp_dir, repo): + # Create a commit + test_file = os.path.join(temp_dir, 'test.py') + with open(test_file, 'w') as f: + f.write("print('test')") + + repo.index.add(['test.py']) + commit = repo.index.commit('Test commit') + + # Create tag + repo.create_tag('v1.0.0', commit) + + with patch('os.getcwd', return_value=temp_dir): + result = calculate_pre_push_commit_range(push_details) + # For new tags, should try to find a merge base or fall back to --all + assert result in [f'{commit.hexsha}..{commit.hexsha}', '--all'] + + +class TestPrePushHookIntegration: + """Integration tests for pre-push hook functionality.""" + + def test_simulate_pre_push_hook_input_format(self) -> None: + """Test that our parsing handles the actual format Git sends to pre-push hooks.""" + # Simulate the exact format Git sends to pre-push hooks + test_cases = [ + # Standard branch push + 'refs/heads/main 67890abcdef12345 refs/heads/main 12345abcdef67890', + # New branch push + f'refs/heads/feature 67890abcdef12345 refs/heads/feature {consts.EMPTY_COMMIT_SHA}', + # Branch deletion + f'refs/heads/old-feature {consts.EMPTY_COMMIT_SHA} refs/heads/old-feature 12345abcdef67890', + # Tag push + f'refs/tags/v1.0.0 67890abcdef12345 refs/tags/v1.0.0 {consts.EMPTY_COMMIT_SHA}', + ] + + for push_input in test_cases: + with patch('sys.stdin', StringIO(push_input)): + parsed = parse_pre_push_input() + assert parsed == push_input + + # Test that we can calculate the commit range for each case + commit_range = calculate_pre_push_commit_range(parsed) + + if consts.EMPTY_COMMIT_SHA in push_input: + if push_input.startswith('refs/heads/') and push_input.split()[1] == consts.EMPTY_COMMIT_SHA: + # Branch deletion - should return None + assert commit_range is None + else: + # New branch/tag - should return a range or --all + assert commit_range is not None + else: + # Regular update - should return proper range + parts = push_input.split() + expected_range = f'{parts[3]}..{parts[1]}' + assert commit_range == expected_range From 0f084cb9efe0254afa1f76d16894bd021c1bce9d Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Tue, 23 Sep 2025 12:15:04 +0200 Subject: [PATCH 200/257] CM-53432 - Fix a broken link to Cursor deeplink for Cycode MCP (#347) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 30f31b9d..a1ccaa7a 100644 --- a/README.md +++ b/README.md @@ -344,7 +344,7 @@ The Model Context Protocol (MCP) command allows you to start an MCP server that > [!TIP] > For the best experience, install Cycode CLI globally on your system using `pip install cycode` or `brew install cycode`, then authenticate once with `cycode auth`. After global installation and authentication, you won't need to configure `CYCODE_CLIENT_ID` and `CYCODE_CLIENT_SECRET` environment variables in your MCP configuration files. -[![Add MCP Server to Cursor using UV](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=cycode&config=eyJjb21tYW5kIjoidXZ4IGN5Y29kZSBtY3AiLCJlbnYiOnsiQ1lDT0RFX0NMSUVOVF9JRCI6InlvdXItY3ljb2RlLWlkIiwiQ1lDT0RFX0NMSUVOVF9TRUNSRVQiOiJ5b3VyLWN5Y29kZS1zZWNyZXQta2V5IiwiQ1lDT0RFX0FQSV9VUkwiOiJodHRwczovL2FwaS5jeWNvZGUuY29tIiwiQ1lDT0RFX0FQUF9VUkwiOiJodHRwczovL2FwcC5jeWNvZGUuY29tIn19) +[![Add MCP Server to Cursor using UV](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=cycode&config=eyJjb21tYW5kIjoidXZ4IGN5Y29kZSBtY3AiLCJlbnYiOnsiQ1lDT0RFX0NMSUVOVF9JRCI6InlvdXItY3ljb2RlLWlkIiwiQ1lDT0RFX0NMSUVOVF9TRUNSRVQiOiJ5b3VyLWN5Y29kZS1zZWNyZXQta2V5IiwiQ1lDT0RFX0FQSV9VUkwiOiJodHRwczovL2FwaS5jeWNvZGUuY29tIiwiQ1lDT0RFX0FQUF9VUkwiOiJodHRwczovL2FwcC5jeWNvZGUuY29tIn19) ## Starting the MCP Server From ee30ee85fe00c2339aec96eeb98e39d25d4fbb42 Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Tue, 23 Sep 2025 11:52:06 +0100 Subject: [PATCH 201/257] CM-53418: update codeowners (#348) --- CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index aba89cba..f05ffdb9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1 +1 @@ -* @MarshalX @elsapet @gotbadger @cfabianski +* @elsapet @gotbadger @mateusz-sterczewski From 58891d862c33d47185ec49f9caa91a3069825fc0 Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Mon, 6 Oct 2025 12:18:10 +0100 Subject: [PATCH 202/257] CM-53667: improve ignore logging (#349) --- cycode/cli/files_collector/walk_ignore.py | 21 ++++++++++++--- cycode/cli/utils/ignore_utils.py | 31 ++++++++++++++++++----- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/cycode/cli/files_collector/walk_ignore.py b/cycode/cli/files_collector/walk_ignore.py index f0e8edd6..fb723109 100644 --- a/cycode/cli/files_collector/walk_ignore.py +++ b/cycode/cli/files_collector/walk_ignore.py @@ -1,9 +1,11 @@ import os from collections.abc import Generator, Iterable -from cycode.cli.logger import logger +from cycode.cli.logger import get_logger from cycode.cli.utils.ignore_utils import IgnoreFilterManager +logger = get_logger('Ignores') + _SUPPORTED_IGNORE_PATTERN_FILES = { '.gitignore', '.cycodeignore', @@ -30,7 +32,7 @@ def _collect_top_level_ignore_files(path: str) -> list[str]: for ignore_file in _SUPPORTED_IGNORE_PATTERN_FILES: ignore_file_path = os.path.join(dir_path, ignore_file) if os.path.exists(ignore_file_path): - logger.debug('Apply top level ignore file: %s', ignore_file_path) + logger.debug('Reading top level ignore file: %s', ignore_file_path) ignore_files.append(ignore_file_path) return ignore_files @@ -41,4 +43,17 @@ def walk_ignore(path: str) -> Generator[tuple[str, list[str], list[str]], None, global_ignore_file_paths=_collect_top_level_ignore_files(path), global_patterns=_DEFAULT_GLOBAL_IGNORE_PATTERNS, ) - yield from ignore_filter_manager.walk() + for dirpath, dirnames, filenames, ignored_dirnames, ignored_filenames in ignore_filter_manager.walk_with_ignored(): + rel_dirpath = '' if dirpath == path else os.path.relpath(dirpath, path) + display_dir = rel_dirpath or '.' + for is_dir, names in ( + (True, ignored_dirnames), + (False, ignored_filenames), + ): + for name in names: + full_path = os.path.join(path, display_dir, name) + if is_dir: + full_path = os.path.join(full_path, '*') + logger.debug('Ignoring match %s', full_path) + + yield dirpath, dirnames, filenames diff --git a/cycode/cli/utils/ignore_utils.py b/cycode/cli/utils/ignore_utils.py index e8994e46..98126658 100644 --- a/cycode/cli/utils/ignore_utils.py +++ b/cycode/cli/utils/ignore_utils.py @@ -388,19 +388,38 @@ def is_ignored(self, path: str) -> Optional[bool]: return matches[-1].is_exclude return None - def walk(self, **kwargs) -> Generator[tuple[str, list[str], list[str]], None, None]: - """Wrap os.walk() without ignored files and subdirectories and kwargs are passed to walk.""" + def walk_with_ignored( + self, **kwargs + ) -> Generator[tuple[str, list[str], list[str], list[str], list[str]], None, None]: + """Wrap os.walk() and also return lists of ignored directories and files. + + Yields tuples: (dirpath, included_dirnames, included_filenames, ignored_dirnames, ignored_filenames) + """ for dirpath, dirnames, filenames in os.walk(self.path, topdown=True, **kwargs): rel_dirpath = '' if dirpath == self.path else os.path.relpath(dirpath, self.path) + original_dirnames = list(dirnames) + included_dirnames = [] + ignored_dirnames = [] + for d in original_dirnames: + if self.is_ignored(os.path.join(rel_dirpath, d)): + ignored_dirnames.append(d) + else: + included_dirnames.append(d) + # decrease recursion depth of os.walk() by ignoring subdirectories because of topdown=True # slicing ([:]) is mandatory to change dict in-place! - dirnames[:] = [d for d in dirnames if not self.is_ignored(os.path.join(rel_dirpath, d))] + dirnames[:] = included_dirnames - # remove ignored files - filenames = [f for f in filenames if not self.is_ignored(os.path.join(rel_dirpath, f))] + included_filenames = [] + ignored_filenames = [] + for f in filenames: + if self.is_ignored(os.path.join(rel_dirpath, f)): + ignored_filenames.append(f) + else: + included_filenames.append(f) - yield dirpath, dirnames, filenames + yield dirpath, dirnames, included_filenames, ignored_dirnames, ignored_filenames @classmethod def build( From f9de189ba60d78f32e1272c25fdc36ac28c59338 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Oct 2025 14:26:11 +0100 Subject: [PATCH 203/257] Bump starlette from 0.47.0 to 0.47.2 (#339) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/poetry.lock b/poetry.lock index 01552065..54f09b5d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand. [[package]] name = "altgraph" @@ -31,8 +31,7 @@ version = "4.9.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.9" -groups = ["main"] -markers = "python_version >= \"3.10\"" +groups = ["main", "dev"] files = [ {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, @@ -352,12 +351,12 @@ version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["main", "test"] +groups = ["main", "dev", "test"] +markers = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, ] -markers = {main = "python_version == \"3.10\"", test = "python_version < \"3.11\""} [package.extras] test = ["pytest (>=6)"] @@ -477,7 +476,7 @@ version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" -groups = ["main", "test"] +groups = ["main", "dev", "test"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -1586,8 +1585,7 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" -groups = ["main"] -markers = "python_version >= \"3.10\"" +groups = ["main", "dev"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -1617,19 +1615,19 @@ uvicorn = ["uvicorn (>=0.34.0)"] [[package]] name = "starlette" -version = "0.47.0" +version = "0.47.2" description = "The little ASGI library that shines." optional = false python-versions = ">=3.9" -groups = ["main"] -markers = "python_version >= \"3.10\"" +groups = ["main", "dev"] files = [ - {file = "starlette-0.47.0-py3-none-any.whl", hash = "sha256:9d052d4933683af40ffd47c7465433570b4949dc937e20ad1d73b34e72f10c37"}, - {file = "starlette-0.47.0.tar.gz", hash = "sha256:1f64887e94a447fed5f23309fb6890ef23349b7e478faa7b24a851cd4eb844af"}, + {file = "starlette-0.47.2-py3-none-any.whl", hash = "sha256:c5847e96134e5c5371ee9fac6fdf1a67336d5815e09eb2a01fdb57a351ef915b"}, + {file = "starlette-0.47.2.tar.gz", hash = "sha256:6ae9aa5db235e4846decc1e7b79c4f346adf41e9777aebeb49dfd09bbd7023d8"}, ] [package.dependencies] anyio = ">=3.6.2,<5" +typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} [package.extras] full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] @@ -1741,11 +1739,12 @@ version = "4.13.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, ] +markers = {dev = "python_version < \"3.13\""} [[package]] name = "typing-inspection" From 57880b4f44d36661ca43d347cb0b99edacac2b39 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Oct 2025 16:49:38 +0100 Subject: [PATCH 204/257] Bump pyinstaller from 5.13.2 to 6.0.0 (#344) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 51 ++++++++++++++++++++++++++------------------------ pyproject.toml | 2 +- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/poetry.lock b/poetry.lock index 54f09b5d..c5bda04d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -31,7 +31,8 @@ version = "4.9.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main"] +markers = "python_version >= \"3.10\"" files = [ {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, @@ -351,12 +352,12 @@ version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["main", "dev", "test"] -markers = "python_version < \"3.11\"" +groups = ["main", "test"] files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, ] +markers = {main = "python_version == \"3.10\"", test = "python_version < \"3.11\""} [package.extras] test = ["pytest (>=6)"] @@ -476,7 +477,7 @@ version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" -groups = ["main", "dev", "test"] +groups = ["main", "test"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -939,37 +940,38 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyinstaller" -version = "5.13.2" +version = "6.0.0" description = "PyInstaller bundles a Python application and all its dependencies into a single package." optional = false -python-versions = "<3.13,>=3.7" +python-versions = "<3.13,>=3.8" groups = ["executable"] markers = "python_version < \"3.13\"" files = [ - {file = "pyinstaller-5.13.2-py3-none-macosx_10_13_universal2.whl", hash = "sha256:16cbd66b59a37f4ee59373a003608d15df180a0d9eb1a29ff3bfbfae64b23d0f"}, - {file = "pyinstaller-5.13.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8f6dd0e797ae7efdd79226f78f35eb6a4981db16c13325e962a83395c0ec7420"}, - {file = "pyinstaller-5.13.2-py3-none-manylinux2014_i686.whl", hash = "sha256:65133ed89467edb2862036b35d7c5ebd381670412e1e4361215e289c786dd4e6"}, - {file = "pyinstaller-5.13.2-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:7d51734423685ab2a4324ab2981d9781b203dcae42839161a9ee98bfeaabdade"}, - {file = "pyinstaller-5.13.2-py3-none-manylinux2014_s390x.whl", hash = "sha256:2c2fe9c52cb4577a3ac39626b84cf16cf30c2792f785502661286184f162ae0d"}, - {file = "pyinstaller-5.13.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c63ef6133eefe36c4b2f4daf4cfea3d6412ece2ca218f77aaf967e52a95ac9b8"}, - {file = "pyinstaller-5.13.2-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:aadafb6f213549a5906829bb252e586e2cf72a7fbdb5731810695e6516f0ab30"}, - {file = "pyinstaller-5.13.2-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:b2e1c7f5cceb5e9800927ddd51acf9cc78fbaa9e79e822c48b0ee52d9ce3c892"}, - {file = "pyinstaller-5.13.2-py3-none-win32.whl", hash = "sha256:421cd24f26144f19b66d3868b49ed673176765f92fa9f7914cd2158d25b6d17e"}, - {file = "pyinstaller-5.13.2-py3-none-win_amd64.whl", hash = "sha256:ddcc2b36052a70052479a9e5da1af067b4496f43686ca3cdda99f8367d0627e4"}, - {file = "pyinstaller-5.13.2-py3-none-win_arm64.whl", hash = "sha256:27cd64e7cc6b74c5b1066cbf47d75f940b71356166031deb9778a2579bb874c6"}, - {file = "pyinstaller-5.13.2.tar.gz", hash = "sha256:c8e5d3489c3a7cc5f8401c2d1f48a70e588f9967e391c3b06ddac1f685f8d5d2"}, + {file = "pyinstaller-6.0.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:d84b06fb9002109bfc542e76860b81459a8585af0bbdabcfc5dcf272ef230de7"}, + {file = "pyinstaller-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:aa922d1d73881d0820a341d2c406a571cc94630bdcdc275427c844a12e6e376e"}, + {file = "pyinstaller-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:52e5b3a2371d7231de17515c7c78d8d4a39d70c8c095e71d55b3b83434a193a8"}, + {file = "pyinstaller-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4a75bde5cda259bb31f2294960d75b9d5c148001b2b0bd20a91f9c2116675a6c"}, + {file = "pyinstaller-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:5314f6f08d2bcbc031778618ba97d9098d106119c2e616b3b081171fe42f5415"}, + {file = "pyinstaller-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:0ad7cc3776ca17d0bededcc352cba2b1c89eb4817bfabaf05972b9da8c424935"}, + {file = "pyinstaller-6.0.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:cccdad6cfe7a5db7d7eb8df2e5678f8375268739d5933214e180da300aa54e37"}, + {file = "pyinstaller-6.0.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:fb6af82989dac7c58bd25ed9ba3323bc443f8c1f03804f69c9f5e363bf4a021c"}, + {file = "pyinstaller-6.0.0-py3-none-win32.whl", hash = "sha256:68769f5e6722474bb1038e35560444659db8b951388bfe0c669bb52a640cd0eb"}, + {file = "pyinstaller-6.0.0-py3-none-win_amd64.whl", hash = "sha256:438a9e0d72a57d5bba4f112d256e39ea4033c76c65414c0693d8311faa14b090"}, + {file = "pyinstaller-6.0.0-py3-none-win_arm64.whl", hash = "sha256:16a473065291dd7879bf596fa20e65bd9d1e8aafc2cef1bffa3e42e707e2e68e"}, + {file = "pyinstaller-6.0.0.tar.gz", hash = "sha256:d702cff041f30e7a53500b630e07b081e5328d4655023319253d73935e75ade2"}, ] [package.dependencies] altgraph = "*" +importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""} +packaging = ">=20.0" pefile = {version = ">=2022.5.30", markers = "sys_platform == \"win32\""} pyinstaller-hooks-contrib = ">=2021.4" pywin32-ctypes = {version = ">=0.2.1", markers = "sys_platform == \"win32\""} setuptools = ">=42.0.0" [package.extras] -encryption = ["tinyaes (>=1.0.0)"] hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"] [[package]] @@ -1585,7 +1587,8 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["main"] +markers = "python_version >= \"3.10\"" files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -1619,7 +1622,8 @@ version = "0.47.2" description = "The little ASGI library that shines." optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main"] +markers = "python_version >= \"3.10\"" files = [ {file = "starlette-0.47.2-py3-none-any.whl", hash = "sha256:c5847e96134e5c5371ee9fac6fdf1a67336d5815e09eb2a01fdb57a351ef915b"}, {file = "starlette-0.47.2.tar.gz", hash = "sha256:6ae9aa5db235e4846decc1e7b79c4f346adf41e9777aebeb49dfd09bbd7023d8"}, @@ -1739,12 +1743,11 @@ version = "4.13.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, ] -markers = {dev = "python_version < \"3.13\""} [[package]] name = "typing-inspection" @@ -1823,4 +1826,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9,<3.14" -content-hash = "a32a53bea8963df5ec2c1a0db09804b8e9466e523488326c20b3f6dd21dee6d2" +content-hash = "1e4addbf558bd806e3ccc3d3e99accb8031ad6f2b67981ae7d38eb4ab877f57f" diff --git a/pyproject.toml b/pyproject.toml index 8fce7854..a332bf26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,7 @@ responses = ">=0.23.1,<0.24.0" pyfakefs = ">=5.7.2,<5.8.0" [tool.poetry.group.executable.dependencies] -pyinstaller = {version=">=5.13.2,<5.14.0", python=">=3.8,<3.13"} +pyinstaller = {version=">=5.13.2,<6.1.0", python=">=3.8,<3.13"} dunamai = ">=1.18.0,<1.22.0" [tool.poetry.group.dev.dependencies] From 2c65f0f9128aea5cf8e4e1cf9b89be77ff93d1d5 Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Tue, 7 Oct 2025 11:38:16 +0100 Subject: [PATCH 205/257] Revert "Bump pyinstaller from 5.13.2 to 6.0.0" (#350) --- poetry.lock | 51 ++++++++++++++++++++++++-------------------------- pyproject.toml | 2 +- 2 files changed, 25 insertions(+), 28 deletions(-) diff --git a/poetry.lock b/poetry.lock index c5bda04d..54f09b5d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -31,8 +31,7 @@ version = "4.9.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.9" -groups = ["main"] -markers = "python_version >= \"3.10\"" +groups = ["main", "dev"] files = [ {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, @@ -352,12 +351,12 @@ version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["main", "test"] +groups = ["main", "dev", "test"] +markers = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, ] -markers = {main = "python_version == \"3.10\"", test = "python_version < \"3.11\""} [package.extras] test = ["pytest (>=6)"] @@ -477,7 +476,7 @@ version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" -groups = ["main", "test"] +groups = ["main", "dev", "test"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -940,38 +939,37 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyinstaller" -version = "6.0.0" +version = "5.13.2" description = "PyInstaller bundles a Python application and all its dependencies into a single package." optional = false -python-versions = "<3.13,>=3.8" +python-versions = "<3.13,>=3.7" groups = ["executable"] markers = "python_version < \"3.13\"" files = [ - {file = "pyinstaller-6.0.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:d84b06fb9002109bfc542e76860b81459a8585af0bbdabcfc5dcf272ef230de7"}, - {file = "pyinstaller-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:aa922d1d73881d0820a341d2c406a571cc94630bdcdc275427c844a12e6e376e"}, - {file = "pyinstaller-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:52e5b3a2371d7231de17515c7c78d8d4a39d70c8c095e71d55b3b83434a193a8"}, - {file = "pyinstaller-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4a75bde5cda259bb31f2294960d75b9d5c148001b2b0bd20a91f9c2116675a6c"}, - {file = "pyinstaller-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:5314f6f08d2bcbc031778618ba97d9098d106119c2e616b3b081171fe42f5415"}, - {file = "pyinstaller-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:0ad7cc3776ca17d0bededcc352cba2b1c89eb4817bfabaf05972b9da8c424935"}, - {file = "pyinstaller-6.0.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:cccdad6cfe7a5db7d7eb8df2e5678f8375268739d5933214e180da300aa54e37"}, - {file = "pyinstaller-6.0.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:fb6af82989dac7c58bd25ed9ba3323bc443f8c1f03804f69c9f5e363bf4a021c"}, - {file = "pyinstaller-6.0.0-py3-none-win32.whl", hash = "sha256:68769f5e6722474bb1038e35560444659db8b951388bfe0c669bb52a640cd0eb"}, - {file = "pyinstaller-6.0.0-py3-none-win_amd64.whl", hash = "sha256:438a9e0d72a57d5bba4f112d256e39ea4033c76c65414c0693d8311faa14b090"}, - {file = "pyinstaller-6.0.0-py3-none-win_arm64.whl", hash = "sha256:16a473065291dd7879bf596fa20e65bd9d1e8aafc2cef1bffa3e42e707e2e68e"}, - {file = "pyinstaller-6.0.0.tar.gz", hash = "sha256:d702cff041f30e7a53500b630e07b081e5328d4655023319253d73935e75ade2"}, + {file = "pyinstaller-5.13.2-py3-none-macosx_10_13_universal2.whl", hash = "sha256:16cbd66b59a37f4ee59373a003608d15df180a0d9eb1a29ff3bfbfae64b23d0f"}, + {file = "pyinstaller-5.13.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8f6dd0e797ae7efdd79226f78f35eb6a4981db16c13325e962a83395c0ec7420"}, + {file = "pyinstaller-5.13.2-py3-none-manylinux2014_i686.whl", hash = "sha256:65133ed89467edb2862036b35d7c5ebd381670412e1e4361215e289c786dd4e6"}, + {file = "pyinstaller-5.13.2-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:7d51734423685ab2a4324ab2981d9781b203dcae42839161a9ee98bfeaabdade"}, + {file = "pyinstaller-5.13.2-py3-none-manylinux2014_s390x.whl", hash = "sha256:2c2fe9c52cb4577a3ac39626b84cf16cf30c2792f785502661286184f162ae0d"}, + {file = "pyinstaller-5.13.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c63ef6133eefe36c4b2f4daf4cfea3d6412ece2ca218f77aaf967e52a95ac9b8"}, + {file = "pyinstaller-5.13.2-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:aadafb6f213549a5906829bb252e586e2cf72a7fbdb5731810695e6516f0ab30"}, + {file = "pyinstaller-5.13.2-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:b2e1c7f5cceb5e9800927ddd51acf9cc78fbaa9e79e822c48b0ee52d9ce3c892"}, + {file = "pyinstaller-5.13.2-py3-none-win32.whl", hash = "sha256:421cd24f26144f19b66d3868b49ed673176765f92fa9f7914cd2158d25b6d17e"}, + {file = "pyinstaller-5.13.2-py3-none-win_amd64.whl", hash = "sha256:ddcc2b36052a70052479a9e5da1af067b4496f43686ca3cdda99f8367d0627e4"}, + {file = "pyinstaller-5.13.2-py3-none-win_arm64.whl", hash = "sha256:27cd64e7cc6b74c5b1066cbf47d75f940b71356166031deb9778a2579bb874c6"}, + {file = "pyinstaller-5.13.2.tar.gz", hash = "sha256:c8e5d3489c3a7cc5f8401c2d1f48a70e588f9967e391c3b06ddac1f685f8d5d2"}, ] [package.dependencies] altgraph = "*" -importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""} -packaging = ">=20.0" pefile = {version = ">=2022.5.30", markers = "sys_platform == \"win32\""} pyinstaller-hooks-contrib = ">=2021.4" pywin32-ctypes = {version = ">=0.2.1", markers = "sys_platform == \"win32\""} setuptools = ">=42.0.0" [package.extras] +encryption = ["tinyaes (>=1.0.0)"] hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"] [[package]] @@ -1587,8 +1585,7 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" -groups = ["main"] -markers = "python_version >= \"3.10\"" +groups = ["main", "dev"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -1622,8 +1619,7 @@ version = "0.47.2" description = "The little ASGI library that shines." optional = false python-versions = ">=3.9" -groups = ["main"] -markers = "python_version >= \"3.10\"" +groups = ["main", "dev"] files = [ {file = "starlette-0.47.2-py3-none-any.whl", hash = "sha256:c5847e96134e5c5371ee9fac6fdf1a67336d5815e09eb2a01fdb57a351ef915b"}, {file = "starlette-0.47.2.tar.gz", hash = "sha256:6ae9aa5db235e4846decc1e7b79c4f346adf41e9777aebeb49dfd09bbd7023d8"}, @@ -1743,11 +1739,12 @@ version = "4.13.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, ] +markers = {dev = "python_version < \"3.13\""} [[package]] name = "typing-inspection" @@ -1826,4 +1823,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9,<3.14" -content-hash = "1e4addbf558bd806e3ccc3d3e99accb8031ad6f2b67981ae7d38eb4ab877f57f" +content-hash = "a32a53bea8963df5ec2c1a0db09804b8e9466e523488326c20b3f6dd21dee6d2" diff --git a/pyproject.toml b/pyproject.toml index a332bf26..8fce7854 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,7 @@ responses = ">=0.23.1,<0.24.0" pyfakefs = ">=5.7.2,<5.8.0" [tool.poetry.group.executable.dependencies] -pyinstaller = {version=">=5.13.2,<6.1.0", python=">=3.8,<3.13"} +pyinstaller = {version=">=5.13.2,<5.14.0", python=">=3.8,<3.13"} dunamai = ">=1.18.0,<1.22.0" [tool.poetry.group.dev.dependencies] From 4e4248855e2a3bdf55c3f7932343987c72e1b073 Mon Sep 17 00:00:00 2001 From: galf16 Date: Thu, 9 Oct 2025 17:35:16 +0300 Subject: [PATCH 206/257] CM-53944-Fix-docker-file-ignore-issue (#352) --- cycode/cli/files_collector/file_excluder.py | 17 ++++++++++++----- .../cli/files_collector/test_file_excluder.py | 18 +++++++++++++++++- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/cycode/cli/files_collector/file_excluder.py b/cycode/cli/files_collector/file_excluder.py index e3c0b41a..11fd3410 100644 --- a/cycode/cli/files_collector/file_excluder.py +++ b/cycode/cli/files_collector/file_excluder.py @@ -69,6 +69,14 @@ def apply_scan_config(self, scan_type: str, scan_config: 'models.ScanConfigurati if scan_config.scannable_extensions: self._scannable_extensions[scan_type] = tuple(scan_config.scannable_extensions) + def _is_file_prefix_supported(self, scan_type: str, file_path: str) -> bool: + scannable_prefixes = self._scannable_prefixes.get(scan_type) + if scannable_prefixes: + path = Path(file_path) + file_name = path.name.lower() + return file_name in scannable_prefixes + return False + def _is_file_extension_supported(self, scan_type: str, filename: str) -> bool: filename = filename.lower() @@ -80,10 +88,6 @@ def _is_file_extension_supported(self, scan_type: str, filename: str) -> bool: if non_scannable_extensions: return not filename.endswith(non_scannable_extensions) - scannable_prefixes = self._scannable_prefixes.get(scan_type) - if scannable_prefixes: - return filename.startswith(scannable_prefixes) - return True def _is_relevant_file_to_scan_common(self, scan_type: str, filename: str) -> bool: @@ -100,7 +104,10 @@ def _is_relevant_file_to_scan_common(self, scan_type: str, filename: str) -> boo ) return False - if not self._is_file_extension_supported(scan_type, filename): + if not ( + self._is_file_extension_supported(scan_type, filename) + or self._is_file_prefix_supported(scan_type, filename) + ): logger.debug( 'The document is irrelevant because its extension is not supported, %s', {'scan_type': scan_type, 'filename': filename}, diff --git a/tests/cli/files_collector/test_file_excluder.py b/tests/cli/files_collector/test_file_excluder.py index 4ac623e3..31a52f55 100644 --- a/tests/cli/files_collector/test_file_excluder.py +++ b/tests/cli/files_collector/test_file_excluder.py @@ -1,7 +1,7 @@ import pytest from cycode.cli import consts -from cycode.cli.files_collector.file_excluder import _is_file_relevant_for_sca_scan +from cycode.cli.files_collector.file_excluder import Excluder, _is_file_relevant_for_sca_scan class TestIsFileRelevantForScaScan: @@ -38,6 +38,22 @@ def test_files_with_excluded_names_in_filename_should_be_included(self) -> None: assert _is_file_relevant_for_sca_scan('utils/pycache_cleaner.py') is True assert _is_file_relevant_for_sca_scan('config/gradle_config.xml') is True + def test_files_with_excluded_extensions_in_should_be_included(self) -> None: + """Test that files containing excluded extensions are NOT excluded.""" + excluder = Excluder() + # These should be INCLUDED because the excluded terms are in the filename + assert excluder._is_relevant_file_to_scan_common('iac', 'project/cfg/Dockerfile') is True + assert excluder._is_relevant_file_to_scan_common('iac', 'project/cfg/build.tf') is True + assert excluder._is_relevant_file_to_scan_common('iac', 'project/cfg/build.tf.json') is True + assert excluder._is_relevant_file_to_scan_common('iac', 'project/cfg/config.json') is True + assert excluder._is_relevant_file_to_scan_common('iac', 'project/cfg/config.yaml') is True + assert excluder._is_relevant_file_to_scan_common('iac', 'project/cfg/config.yml') is True + # These should be EXCLUDED because the excluded terms are not in the filename + assert excluder._is_relevant_file_to_scan_common('iac', 'project/cfg/build') is False + assert excluder._is_relevant_file_to_scan_common('iac', 'project/cfg/build') is False + assert excluder._is_relevant_file_to_scan_common('iac', 'project/cfg/Dockerfile.txt') is False + assert excluder._is_relevant_file_to_scan_common('iac', 'project/cfg/config.ini') is False + def test_files_in_regular_directories_should_be_included(self) -> None: """Test that files in regular directories (not excluded) are included.""" From c0bf1900665db034a656eee65e8de22bd70a6877 Mon Sep 17 00:00:00 2001 From: Dmytro Lynda <58661187+DmytroLynda@users.noreply.github.com> Date: Fri, 10 Oct 2025 13:42:31 +0200 Subject: [PATCH 207/257] CM-53929 Add full support of .cycodeignore for repository and commit range scans (#351) --- .../cli/apps/report/sbom/path/path_command.py | 7 +- cycode/cli/apps/scan/code_scanner.py | 14 +- cycode/cli/apps/scan/commit_range_scanner.py | 41 +- .../scan/repository/repository_command.py | 5 + cycode/cli/apps/scan/scan_command.py | 9 +- cycode/cli/consts.py | 2 + .../files_collector/documents_walk_ignore.py | 124 +++++ cycode/cli/files_collector/path_documents.py | 31 +- cycode/cli/files_collector/walk_ignore.py | 19 +- cycode/cli/utils/scan_utils.py | 8 +- cycode/cyclient/models.py | 2 + cycode/cyclient/scan_client.py | 13 +- .../test_documents_walk_ignore.py | 430 ++++++++++++++++++ 13 files changed, 684 insertions(+), 21 deletions(-) create mode 100644 cycode/cli/files_collector/documents_walk_ignore.py create mode 100644 tests/cli/files_collector/test_documents_walk_ignore.py diff --git a/cycode/cli/apps/report/sbom/path/path_command.py b/cycode/cli/apps/report/sbom/path/path_command.py index 9c839b08..61c9ddb7 100644 --- a/cycode/cli/apps/report/sbom/path/path_command.py +++ b/cycode/cli/apps/report/sbom/path/path_command.py @@ -12,6 +12,7 @@ from cycode.cli.files_collector.zip_documents import zip_documents from cycode.cli.utils.get_api_client import get_report_cycode_client from cycode.cli.utils.progress_bar import SbomReportProgressBarSection +from cycode.cli.utils.scan_utils import is_cycodeignore_allowed_by_scan_config from cycode.cli.utils.sentry import add_breadcrumb @@ -37,7 +38,11 @@ def path_command( try: documents = get_relevant_documents( - progress_bar, SbomReportProgressBarSection.PREPARE_LOCAL_FILES, consts.SCA_SCAN_TYPE, (str(path),) + progress_bar, + SbomReportProgressBarSection.PREPARE_LOCAL_FILES, + consts.SCA_SCAN_TYPE, + (str(path),), + is_cycodeignore_allowed=is_cycodeignore_allowed_by_scan_config(ctx), ) # TODO(MarshalX): combine perform_pre_scan_documents_actions with get_relevant_document. # unhardcode usage of context in perform_pre_scan_documents_actions diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index ad6a6e3e..5b4c3e78 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -23,7 +23,11 @@ from cycode.cli.models import CliError, Document, LocalScanResult from cycode.cli.utils.progress_bar import ScanProgressBarSection from cycode.cli.utils.scan_batch import run_parallel_batched_scan -from cycode.cli.utils.scan_utils import generate_unique_scan_id, set_issue_detected_by_scan_results +from cycode.cli.utils.scan_utils import ( + generate_unique_scan_id, + is_cycodeignore_allowed_by_scan_config, + set_issue_detected_by_scan_results, +) from cycode.cyclient.models import ZippedFileScanResult from cycode.logger import get_logger @@ -42,7 +46,13 @@ def scan_disk_files(ctx: typer.Context, paths: tuple[str, ...]) -> None: progress_bar = ctx.obj['progress_bar'] try: - documents = get_relevant_documents(progress_bar, ScanProgressBarSection.PREPARE_LOCAL_FILES, scan_type, paths) + documents = get_relevant_documents( + progress_bar, + ScanProgressBarSection.PREPARE_LOCAL_FILES, + scan_type, + paths, + is_cycodeignore_allowed=is_cycodeignore_allowed_by_scan_config(ctx), + ) add_sca_dependencies_tree_documents_if_needed(ctx, scan_type, documents) scan_documents(ctx, documents, get_scan_parameters(ctx, paths)) except Exception as e: diff --git a/cycode/cli/apps/scan/commit_range_scanner.py b/cycode/cli/apps/scan/commit_range_scanner.py index 3abd2940..335531c2 100644 --- a/cycode/cli/apps/scan/commit_range_scanner.py +++ b/cycode/cli/apps/scan/commit_range_scanner.py @@ -29,6 +29,7 @@ parse_commit_range_sast, parse_commit_range_sca, ) +from cycode.cli.files_collector.documents_walk_ignore import filter_documents_with_cycodeignore from cycode.cli.files_collector.file_excluder import excluder from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip from cycode.cli.files_collector.sca.sca_file_collector import ( @@ -40,7 +41,11 @@ from cycode.cli.utils.git_proxy import git_proxy from cycode.cli.utils.path_utils import get_path_by_os from cycode.cli.utils.progress_bar import ScanProgressBarSection -from cycode.cli.utils.scan_utils import generate_unique_scan_id, set_issue_detected_by_scan_results +from cycode.cli.utils.scan_utils import ( + generate_unique_scan_id, + is_cycodeignore_allowed_by_scan_config, + set_issue_detected_by_scan_results, +) from cycode.cyclient.models import ZippedFileScanResult from cycode.logger import get_logger @@ -189,6 +194,12 @@ def _scan_sca_commit_range(ctx: typer.Context, repo_path: str, commit_range: str from_commit_documents = excluder.exclude_irrelevant_documents_to_scan(consts.SCA_SCAN_TYPE, from_commit_documents) to_commit_documents = excluder.exclude_irrelevant_documents_to_scan(consts.SCA_SCAN_TYPE, to_commit_documents) + is_cycodeignore_allowed = is_cycodeignore_allowed_by_scan_config(ctx) + from_commit_documents = filter_documents_with_cycodeignore( + from_commit_documents, repo_path, is_cycodeignore_allowed + ) + to_commit_documents = filter_documents_with_cycodeignore(to_commit_documents, repo_path, is_cycodeignore_allowed) + perform_sca_pre_commit_range_scan_actions( repo_path, from_commit_documents, from_commit_rev, to_commit_documents, to_commit_rev ) @@ -204,6 +215,11 @@ def _scan_secret_commit_range( consts.SECRET_SCAN_TYPE, commit_diff_documents_to_scan ) + is_cycodeignore_allowed = is_cycodeignore_allowed_by_scan_config(ctx) + diff_documents_to_scan = filter_documents_with_cycodeignore( + diff_documents_to_scan, repo_path, is_cycodeignore_allowed + ) + scan_documents( ctx, diff_documents_to_scan, get_scan_parameters(ctx, (repo_path,)), is_git_diff=True, is_commit_range=True ) @@ -221,9 +237,14 @@ def _scan_sast_commit_range(ctx: typer.Context, repo_path: str, commit_range: st to_commit_rev, reverse_diff=False, ) + commit_documents = excluder.exclude_irrelevant_documents_to_scan(consts.SAST_SCAN_TYPE, commit_documents) diff_documents = excluder.exclude_irrelevant_documents_to_scan(consts.SAST_SCAN_TYPE, diff_documents) + is_cycodeignore_allowed = is_cycodeignore_allowed_by_scan_config(ctx) + commit_documents = filter_documents_with_cycodeignore(commit_documents, repo_path, is_cycodeignore_allowed) + diff_documents = filter_documents_with_cycodeignore(diff_documents, repo_path, is_cycodeignore_allowed) + _scan_commit_range_documents(ctx, commit_documents, diff_documents, scan_parameters=scan_parameters) @@ -254,11 +275,18 @@ def _scan_sca_pre_commit(ctx: typer.Context, repo_path: str) -> None: progress_bar_section=ScanProgressBarSection.PREPARE_LOCAL_FILES, repo_path=repo_path, ) + git_head_documents = excluder.exclude_irrelevant_documents_to_scan(consts.SCA_SCAN_TYPE, git_head_documents) pre_committed_documents = excluder.exclude_irrelevant_documents_to_scan( consts.SCA_SCAN_TYPE, pre_committed_documents ) + is_cycodeignore_allowed = is_cycodeignore_allowed_by_scan_config(ctx) + git_head_documents = filter_documents_with_cycodeignore(git_head_documents, repo_path, is_cycodeignore_allowed) + pre_committed_documents = filter_documents_with_cycodeignore( + pre_committed_documents, repo_path, is_cycodeignore_allowed + ) + perform_sca_pre_hook_range_scan_actions(repo_path, git_head_documents, pre_committed_documents) _scan_commit_range_documents( @@ -288,8 +316,12 @@ def _scan_secret_pre_commit(ctx: typer.Context, repo_path: str) -> None: is_git_diff_format=True, ) ) + documents_to_scan = excluder.exclude_irrelevant_documents_to_scan(consts.SECRET_SCAN_TYPE, documents_to_scan) + is_cycodeignore_allowed = is_cycodeignore_allowed_by_scan_config(ctx) + documents_to_scan = filter_documents_with_cycodeignore(documents_to_scan, repo_path, is_cycodeignore_allowed) + scan_documents(ctx, documents_to_scan, get_scan_parameters(ctx), is_git_diff=True) @@ -301,11 +333,18 @@ def _scan_sast_pre_commit(ctx: typer.Context, repo_path: str, **_) -> None: progress_bar_section=ScanProgressBarSection.PREPARE_LOCAL_FILES, repo_path=repo_path, ) + pre_committed_documents = excluder.exclude_irrelevant_documents_to_scan( consts.SAST_SCAN_TYPE, pre_committed_documents ) diff_documents = excluder.exclude_irrelevant_documents_to_scan(consts.SAST_SCAN_TYPE, diff_documents) + is_cycodeignore_allowed = is_cycodeignore_allowed_by_scan_config(ctx) + pre_committed_documents = filter_documents_with_cycodeignore( + pre_committed_documents, repo_path, is_cycodeignore_allowed + ) + diff_documents = filter_documents_with_cycodeignore(diff_documents, repo_path, is_cycodeignore_allowed) + _scan_commit_range_documents(ctx, pre_committed_documents, diff_documents, scan_parameters=scan_parameters) diff --git a/cycode/cli/apps/scan/repository/repository_command.py b/cycode/cli/apps/scan/repository/repository_command.py index 6fc77bee..9692ccc4 100644 --- a/cycode/cli/apps/scan/repository/repository_command.py +++ b/cycode/cli/apps/scan/repository/repository_command.py @@ -8,6 +8,7 @@ from cycode.cli.apps.scan.code_scanner import scan_documents from cycode.cli.apps.scan.scan_parameters import get_scan_parameters from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception +from cycode.cli.files_collector.documents_walk_ignore import filter_documents_with_cycodeignore from cycode.cli.files_collector.file_excluder import excluder from cycode.cli.files_collector.repository_documents import get_git_repository_tree_file_entries from cycode.cli.files_collector.sca.sca_file_collector import add_sca_dependencies_tree_documents_if_needed @@ -15,6 +16,7 @@ from cycode.cli.models import Document from cycode.cli.utils.path_utils import get_path_by_os from cycode.cli.utils.progress_bar import ScanProgressBarSection +from cycode.cli.utils.scan_utils import is_cycodeignore_allowed_by_scan_config from cycode.cli.utils.sentry import add_breadcrumb @@ -60,6 +62,9 @@ def repository_command( documents_to_scan = excluder.exclude_irrelevant_documents_to_scan(scan_type, documents_to_scan) + is_cycodeignore_allowed = is_cycodeignore_allowed_by_scan_config(ctx) + documents_to_scan = filter_documents_with_cycodeignore(documents_to_scan, str(path), is_cycodeignore_allowed) + add_sca_dependencies_tree_documents_if_needed(ctx, scan_type, documents_to_scan) logger.debug('Found all relevant files for scanning %s', {'path': path, 'branch': branch}) diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index dda94876..2eb51f12 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -1,9 +1,11 @@ +import os from pathlib import Path from typing import Annotated, Optional import click import typer +from cycode.cli.apps.scan.remote_url_resolver import _try_get_git_remote_url from cycode.cli.cli_types import ExportTypeOption, ScanTypeOption, ScaScanTypeOption, SeverityOption from cycode.cli.consts import ( ISSUE_DETECTED_STATUS_CODE, @@ -161,10 +163,15 @@ def scan_command( scan_client = get_scan_cycode_client(ctx) ctx.obj['client'] = scan_client - remote_scan_config = scan_client.get_scan_configuration_safe(scan_type) + # Get remote URL from current working directory + remote_url = _try_get_git_remote_url(os.getcwd()) + + remote_scan_config = scan_client.get_scan_configuration_safe(scan_type, remote_url) if remote_scan_config: excluder.apply_scan_config(str(scan_type), remote_scan_config) + ctx.obj['scan_config'] = remote_scan_config + if export_type and export_file: console_printer = ctx.obj['console_printer'] console_printer.enable_recording(export_type, export_file) diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 7384e33e..1b1497bd 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -17,6 +17,8 @@ IAC_SCAN_SUPPORTED_FILE_EXTENSIONS = ('.tf', '.tf.json', '.json', '.yaml', '.yml', '.dockerfile', '.containerfile') IAC_SCAN_SUPPORTED_FILE_PREFIXES = ('dockerfile', 'containerfile') +CYCODEIGNORE_FILENAME = '.cycodeignore' + SECRET_SCAN_FILE_EXTENSIONS_TO_IGNORE = ( '.DS_Store', '.bmp', diff --git a/cycode/cli/files_collector/documents_walk_ignore.py b/cycode/cli/files_collector/documents_walk_ignore.py new file mode 100644 index 00000000..5a4dbe6d --- /dev/null +++ b/cycode/cli/files_collector/documents_walk_ignore.py @@ -0,0 +1,124 @@ +import os +from typing import TYPE_CHECKING + +from cycode.cli import consts +from cycode.cli.logger import get_logger +from cycode.cli.utils.ignore_utils import IgnoreFilterManager + +if TYPE_CHECKING: + from cycode.cli.models import Document + +logger = get_logger('Documents Ignores') + + +def _get_cycodeignore_path(repo_path: str) -> str: + """Get the path to .cycodeignore file in the repository root.""" + return os.path.join(repo_path, consts.CYCODEIGNORE_FILENAME) + + +def _create_ignore_filter_manager(repo_path: str, cycodeignore_path: str) -> IgnoreFilterManager: + """Create IgnoreFilterManager with .cycodeignore file.""" + return IgnoreFilterManager.build( + path=repo_path, + global_ignore_file_paths=[cycodeignore_path], + global_patterns=[], + ) + + +def _log_ignored_files(repo_path: str, dirpath: str, ignored_dirnames: list[str], ignored_filenames: list[str]) -> None: + """Log ignored files for debugging (similar to walk_ignore function).""" + rel_dirpath = '' if dirpath == repo_path else os.path.relpath(dirpath, repo_path) + display_dir = rel_dirpath or '.' + + for is_dir, names in ( + (True, ignored_dirnames), + (False, ignored_filenames), + ): + for name in names: + full_path = os.path.join(repo_path, display_dir, name) + if is_dir: + full_path = os.path.join(full_path, '*') + logger.debug('Ignoring match %s', full_path) + + +def _build_allowed_paths_set(ignore_filter_manager: IgnoreFilterManager, repo_path: str) -> set[str]: + """Build set of allowed file paths using walk_with_ignored.""" + allowed_paths = set() + + for dirpath, _dirnames, filenames, ignored_dirnames, ignored_filenames in ignore_filter_manager.walk_with_ignored(): + _log_ignored_files(repo_path, dirpath, ignored_dirnames, ignored_filenames) + + for filename in filenames: + file_path = os.path.join(dirpath, filename) + allowed_paths.add(file_path) + + return allowed_paths + + +def _get_document_check_path(document: 'Document', repo_path: str) -> str: + """Get the normalized absolute path for a document to check against allowed paths.""" + check_path = document.absolute_path + if not check_path: + check_path = document.path if os.path.isabs(document.path) else os.path.join(repo_path, document.path) + + return os.path.normpath(check_path) + + +def _filter_documents_by_allowed_paths( + documents: list['Document'], allowed_paths: set[str], repo_path: str +) -> list['Document']: + """Filter documents by checking if their paths are in the allowed set.""" + filtered_documents = [] + + for document in documents: + try: + check_path = _get_document_check_path(document, repo_path) + + if check_path in allowed_paths: + filtered_documents.append(document) + else: + relative_path = os.path.relpath(check_path, repo_path) + logger.debug('Filtered out document due to .cycodeignore: %s', relative_path) + except Exception as e: + logger.debug('Error processing document %s: %s', document.path, e) + filtered_documents.append(document) + + return filtered_documents + + +def filter_documents_with_cycodeignore( + documents: list['Document'], repo_path: str, is_cycodeignore_allowed: bool = True +) -> list['Document']: + """Filter documents based on .cycodeignore patterns. + + This function uses .cycodeignore file in the repository root to filter out + documents whose paths match any of those patterns. + + Args: + documents: List of Document objects to filter + repo_path: Path to the repository root + is_cycodeignore_allowed: Whether .cycodeignore filtering is allowed by scan configuration + + Returns: + List of Document objects that don't match any .cycodeignore patterns + """ + if not is_cycodeignore_allowed: + logger.debug('.cycodeignore filtering is not allowed by scan configuration') + return documents + + cycodeignore_path = _get_cycodeignore_path(repo_path) + + if not os.path.exists(cycodeignore_path): + logger.debug('.cycodeignore file does not exist in the repository root') + return documents + + logger.info('Using %s for filtering documents', cycodeignore_path) + + ignore_filter_manager = _create_ignore_filter_manager(repo_path, cycodeignore_path) + + allowed_paths = _build_allowed_paths_set(ignore_filter_manager, repo_path) + + filtered_documents = _filter_documents_by_allowed_paths(documents, allowed_paths, repo_path) + + logger.debug('Filtered %d documents using .cycodeignore patterns', len(documents) - len(filtered_documents)) + return filtered_documents diff --git a/cycode/cli/files_collector/path_documents.py b/cycode/cli/files_collector/path_documents.py index 73cd0768..142c63bf 100644 --- a/cycode/cli/files_collector/path_documents.py +++ b/cycode/cli/files_collector/path_documents.py @@ -1,4 +1,5 @@ import os +from collections.abc import Generator from typing import TYPE_CHECKING from cycode.cli.files_collector.file_excluder import excluder @@ -17,10 +18,18 @@ from cycode.cli.utils.progress_bar import BaseProgressBar, ProgressBarSection -def _get_all_existing_files_in_directory(path: str, *, walk_with_ignore_patterns: bool = True) -> list[str]: +def _get_all_existing_files_in_directory( + path: str, *, walk_with_ignore_patterns: bool = True, is_cycodeignore_allowed: bool = True +) -> list[str]: files: list[str] = [] - walk_func = walk_ignore if walk_with_ignore_patterns else os.walk + if walk_with_ignore_patterns: + + def walk_func(path: str) -> Generator[tuple[str, list[str], list[str]], None, None]: + return walk_ignore(path, is_cycodeignore_allowed=is_cycodeignore_allowed) + else: + walk_func = os.walk + for root, _, filenames in walk_func(path): for filename in filenames: files.append(os.path.join(root, filename)) @@ -28,7 +37,7 @@ def _get_all_existing_files_in_directory(path: str, *, walk_with_ignore_patterns return files -def _get_relevant_files_in_path(path: str) -> list[str]: +def _get_relevant_files_in_path(path: str, *, is_cycodeignore_allowed: bool = True) -> list[str]: absolute_path = get_absolute_path(path) if not os.path.isfile(absolute_path) and not os.path.isdir(absolute_path): @@ -37,16 +46,21 @@ def _get_relevant_files_in_path(path: str) -> list[str]: if os.path.isfile(absolute_path): return [absolute_path] - file_paths = _get_all_existing_files_in_directory(absolute_path) + file_paths = _get_all_existing_files_in_directory(absolute_path, is_cycodeignore_allowed=is_cycodeignore_allowed) return [file_path for file_path in file_paths if os.path.isfile(file_path)] def _get_relevant_files( - progress_bar: 'BaseProgressBar', progress_bar_section: 'ProgressBarSection', scan_type: str, paths: tuple[str, ...] + progress_bar: 'BaseProgressBar', + progress_bar_section: 'ProgressBarSection', + scan_type: str, + paths: tuple[str, ...], + *, + is_cycodeignore_allowed: bool = True, ) -> list[str]: all_files_to_scan = [] for path in paths: - all_files_to_scan.extend(_get_relevant_files_in_path(path)) + all_files_to_scan.extend(_get_relevant_files_in_path(path, is_cycodeignore_allowed=is_cycodeignore_allowed)) # we are double the progress bar section length because we are going to process the files twice # first time to get the file list with respect of excluded patterns (excluding takes seconds to execute) @@ -94,8 +108,11 @@ def get_relevant_documents( paths: tuple[str, ...], *, is_git_diff: bool = False, + is_cycodeignore_allowed: bool = True, ) -> list[Document]: - relevant_files = _get_relevant_files(progress_bar, progress_bar_section, scan_type, paths) + relevant_files = _get_relevant_files( + progress_bar, progress_bar_section, scan_type, paths, is_cycodeignore_allowed=is_cycodeignore_allowed + ) documents: list[Document] = [] for file in relevant_files: diff --git a/cycode/cli/files_collector/walk_ignore.py b/cycode/cli/files_collector/walk_ignore.py index fb723109..0c9d53a3 100644 --- a/cycode/cli/files_collector/walk_ignore.py +++ b/cycode/cli/files_collector/walk_ignore.py @@ -1,6 +1,7 @@ import os from collections.abc import Generator, Iterable +from cycode.cli import consts from cycode.cli.logger import get_logger from cycode.cli.utils.ignore_utils import IgnoreFilterManager @@ -8,7 +9,6 @@ _SUPPORTED_IGNORE_PATTERN_FILES = { '.gitignore', - '.cycodeignore', } _DEFAULT_GLOBAL_IGNORE_PATTERNS = [ '.git', @@ -25,11 +25,17 @@ def _walk_to_top(path: str) -> Iterable[str]: yield path # Include the top-level directory -def _collect_top_level_ignore_files(path: str) -> list[str]: +def _collect_top_level_ignore_files(path: str, *, is_cycodeignore_allowed: bool = True) -> list[str]: ignore_files = [] top_paths = reversed(list(_walk_to_top(path))) # we must reverse it to make top levels more prioritized + + supported_files = set(_SUPPORTED_IGNORE_PATTERN_FILES) + if is_cycodeignore_allowed: + supported_files.add(consts.CYCODEIGNORE_FILENAME) + logger.debug('.cycodeignore files included due to scan configuration') + for dir_path in top_paths: - for ignore_file in _SUPPORTED_IGNORE_PATTERN_FILES: + for ignore_file in supported_files: ignore_file_path = os.path.join(dir_path, ignore_file) if os.path.exists(ignore_file_path): logger.debug('Reading top level ignore file: %s', ignore_file_path) @@ -37,10 +43,13 @@ def _collect_top_level_ignore_files(path: str) -> list[str]: return ignore_files -def walk_ignore(path: str) -> Generator[tuple[str, list[str], list[str]], None, None]: +def walk_ignore( + path: str, *, is_cycodeignore_allowed: bool = True +) -> Generator[tuple[str, list[str], list[str]], None, None]: + ignore_file_paths = _collect_top_level_ignore_files(path, is_cycodeignore_allowed=is_cycodeignore_allowed) ignore_filter_manager = IgnoreFilterManager.build( path=path, - global_ignore_file_paths=_collect_top_level_ignore_files(path), + global_ignore_file_paths=ignore_file_paths, global_patterns=_DEFAULT_GLOBAL_IGNORE_PATTERNS, ) for dirpath, dirnames, filenames, ignored_dirnames, ignored_filenames in ignore_filter_manager.walk_with_ignored(): diff --git a/cycode/cli/utils/scan_utils.py b/cycode/cli/utils/scan_utils.py index 57586b51..1332a7cf 100644 --- a/cycode/cli/utils/scan_utils.py +++ b/cycode/cli/utils/scan_utils.py @@ -1,11 +1,12 @@ import os -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from uuid import UUID, uuid4 import typer if TYPE_CHECKING: from cycode.cli.models import LocalScanResult + from cycode.cyclient.models import ScanConfiguration def set_issue_detected(ctx: typer.Context, issue_detected: bool) -> None: @@ -22,6 +23,11 @@ def is_scan_failed(ctx: typer.Context) -> bool: return did_fail or issue_detected +def is_cycodeignore_allowed_by_scan_config(ctx: typer.Context) -> bool: + scan_config: Optional[ScanConfiguration] = ctx.obj.get('scan_config') + return scan_config.is_cycode_ignore_allowed if scan_config else True + + def generate_unique_scan_id() -> UUID: if 'PYTEST_TEST_UNIQUE_ID' in os.environ: return UUID(os.environ['PYTEST_TEST_UNIQUE_ID']) diff --git a/cycode/cyclient/models.py b/cycode/cyclient/models.py index fa952985..f6419645 100644 --- a/cycode/cyclient/models.py +++ b/cycode/cyclient/models.py @@ -505,6 +505,7 @@ def build_dto(self, data: dict[str, Any], **_) -> 'SupportedModulesPreferences': @dataclass class ScanConfiguration: scannable_extensions: list[str] + is_cycode_ignore_allowed: bool class ScanConfigurationSchema(Schema): @@ -512,6 +513,7 @@ class Meta: unknown = EXCLUDE scannable_extensions = fields.List(fields.String(), allow_none=True) + is_cycode_ignore_allowed = fields.Boolean(load_default=True) @post_load def build_dto(self, data: dict[str, Any], **_) -> 'ScanConfiguration': diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index 6ddce8d5..4f2debca 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -280,16 +280,23 @@ def get_scan_configuration_path(self, scan_type: str) -> str: correct_scan_type = self.scan_config.get_async_scan_type(scan_type) return f'{self.get_scan_service_url_path(scan_type)}/{correct_scan_type}/configuration' - def get_scan_configuration(self, scan_type: str) -> models.ScanConfiguration: + def get_scan_configuration(self, scan_type: str, remote_url: Optional[str] = None) -> models.ScanConfiguration: + params = {} + if remote_url: + params['remote_url'] = remote_url + response = self.scan_cycode_client.get( url_path=self.get_scan_configuration_path(scan_type), + params=params, hide_response_content_log=self._hide_response_log, ) return models.ScanConfigurationSchema().load(response.json()) - def get_scan_configuration_safe(self, scan_type: str) -> Optional['models.ScanConfiguration']: + def get_scan_configuration_safe( + self, scan_type: str, remote_url: Optional[str] = None + ) -> Optional['models.ScanConfiguration']: try: - return self.get_scan_configuration(scan_type) + return self.get_scan_configuration(scan_type, remote_url) except RequestHttpError as e: if e.status_code == 404: logger.debug( diff --git a/tests/cli/files_collector/test_documents_walk_ignore.py b/tests/cli/files_collector/test_documents_walk_ignore.py new file mode 100644 index 00000000..b92cb96e --- /dev/null +++ b/tests/cli/files_collector/test_documents_walk_ignore.py @@ -0,0 +1,430 @@ +import os +from os.path import normpath +from typing import TYPE_CHECKING + +from cycode.cli.files_collector.documents_walk_ignore import ( + _build_allowed_paths_set, + _create_ignore_filter_manager, + _filter_documents_by_allowed_paths, + _get_cycodeignore_path, + _get_document_check_path, + filter_documents_with_cycodeignore, +) +from cycode.cli.models import Document + +if TYPE_CHECKING: + from pyfakefs.fake_filesystem import FakeFilesystem + + +# we are using normpath() in every test to provide multi-platform support + + +def _create_mocked_file_structure(fs: 'FakeFilesystem') -> None: + """Create a mock file structure for testing.""" + fs.create_dir('/home/user/project') + fs.create_dir('/home/user/.git') + + fs.create_dir('/home/user/project/.cycode') + fs.create_file('/home/user/project/.cycode/config.yaml') + fs.create_dir('/home/user/project/.git') + fs.create_file('/home/user/project/.git/HEAD') + + # Create .cycodeignore with patterns + fs.create_file('/home/user/project/.cycodeignore', contents='*.pyc\n*.log\nbuild/\n# comment line\n\n') + + # Create test files that should be filtered + fs.create_file('/home/user/project/ignored.pyc') + fs.create_file('/home/user/project/ignored.log') + fs.create_file('/home/user/project/presented.txt') + fs.create_file('/home/user/project/presented.py') + + # Create build directory with files (should be ignored) + fs.create_dir('/home/user/project/build') + fs.create_file('/home/user/project/build/output.js') + fs.create_file('/home/user/project/build/bundle.css') + + # Create subdirectory + fs.create_dir('/home/user/project/src') + fs.create_file('/home/user/project/src/main.py') + fs.create_file('/home/user/project/src/debug.log') # should be ignored + fs.create_file('/home/user/project/src/temp.pyc') # should be ignored + + +def _create_test_documents(repo_path: str) -> list[Document]: + """Create test Document objects for the mocked file structure.""" + documents = [] + + # Files in root + documents.append( + Document( + path='ignored.pyc', + content='# compiled python', + absolute_path=normpath(os.path.join(repo_path, 'ignored.pyc')), + ) + ) + documents.append( + Document( + path='ignored.log', content='log content', absolute_path=normpath(os.path.join(repo_path, 'ignored.log')) + ) + ) + documents.append( + Document( + path='presented.txt', + content='text content', + absolute_path=normpath(os.path.join(repo_path, 'presented.txt')), + ) + ) + documents.append( + Document( + path='presented.py', + content='print("hello")', + absolute_path=normpath(os.path.join(repo_path, 'presented.py')), + ) + ) + + # Files in build directory (should be ignored) + documents.append( + Document( + path='build/output.js', + content='console.log("build");', + absolute_path=normpath(os.path.join(repo_path, 'build/output.js')), + ) + ) + documents.append( + Document( + path='build/bundle.css', + content='body { color: red; }', + absolute_path=normpath(os.path.join(repo_path, 'build/bundle.css')), + ) + ) + + # Files in src directory + documents.append( + Document( + path='src/main.py', + content='def main(): pass', + absolute_path=normpath(os.path.join(repo_path, 'src/main.py')), + ) + ) + documents.append( + Document( + path='src/debug.log', content='debug info', absolute_path=normpath(os.path.join(repo_path, 'src/debug.log')) + ) + ) + documents.append( + Document( + path='src/temp.pyc', content='compiled', absolute_path=normpath(os.path.join(repo_path, 'src/temp.pyc')) + ) + ) + + return documents + + +def test_get_cycodeignore_path() -> None: + """Test _get_cycodeignore_path helper function.""" + repo_path = normpath('/home/user/project') + expected = normpath('/home/user/project/.cycodeignore') + result = _get_cycodeignore_path(repo_path) + assert result == expected + + +def test_create_ignore_filter_manager(fs: 'FakeFilesystem') -> None: + """Test _create_ignore_filter_manager helper function.""" + _create_mocked_file_structure(fs) + + repo_path = normpath('/home/user/project') + cycodeignore_path = normpath('/home/user/project/.cycodeignore') + + manager = _create_ignore_filter_manager(repo_path, cycodeignore_path) + assert manager is not None + + # Test that it can walk the directory + walked_dirs = list(manager.walk_with_ignored()) + assert len(walked_dirs) > 0 + + +def test_get_document_check_path() -> None: + """Test _get_document_check_path helper function.""" + repo_path = normpath('/home/user/project') + + # Test document with absolute_path + doc_with_abs = Document( + path='src/main.py', content='code', absolute_path=normpath('/home/user/project/src/main.py') + ) + result = _get_document_check_path(doc_with_abs, repo_path) + assert result == normpath('/home/user/project/src/main.py') + + # Test document without absolute_path but with absolute path + doc_abs_path = Document(path=normpath('/home/user/project/src/main.py'), content='code') + result = _get_document_check_path(doc_abs_path, repo_path) + assert result == normpath('/home/user/project/src/main.py') + + # Test document with relative path + doc_rel_path = Document(path='src/main.py', content='code') + result = _get_document_check_path(doc_rel_path, repo_path) + assert result == normpath('/home/user/project/src/main.py') + + +def test_build_allowed_paths_set(fs: 'FakeFilesystem') -> None: + """Test _build_allowed_paths_set helper function.""" + _create_mocked_file_structure(fs) + + repo_path = normpath('/home/user/project') + cycodeignore_path = normpath('/home/user/project/.cycodeignore') + + manager = _create_ignore_filter_manager(repo_path, cycodeignore_path) + allowed_paths = _build_allowed_paths_set(manager, repo_path) + + # Check that allowed files are in the set + assert normpath('/home/user/project/presented.txt') in allowed_paths + assert normpath('/home/user/project/presented.py') in allowed_paths + assert normpath('/home/user/project/src/main.py') in allowed_paths + assert normpath('/home/user/project/.cycodeignore') in allowed_paths + + # Check that ignored files are NOT in the set + assert normpath('/home/user/project/ignored.pyc') not in allowed_paths + assert normpath('/home/user/project/ignored.log') not in allowed_paths + assert normpath('/home/user/project/src/debug.log') not in allowed_paths + assert normpath('/home/user/project/src/temp.pyc') not in allowed_paths + assert normpath('/home/user/project/build/output.js') not in allowed_paths + assert normpath('/home/user/project/build/bundle.css') not in allowed_paths + + +def test_filter_documents_by_allowed_paths() -> None: + """Test _filter_documents_by_allowed_paths helper function.""" + repo_path = normpath('/home/user/project') + + # Create test documents + documents = [ + Document(path='allowed.txt', content='content', absolute_path=normpath('/home/user/project/allowed.txt')), + Document(path='ignored.txt', content='content', absolute_path=normpath('/home/user/project/ignored.txt')), + ] + + # Create allowed paths set (only allow first document) + allowed_paths = {normpath('/home/user/project/allowed.txt')} + + result = _filter_documents_by_allowed_paths(documents, allowed_paths, repo_path) + + assert len(result) == 1 + assert result[0].path == 'allowed.txt' + + +def test_filter_documents_with_cycodeignore_no_ignore_file(fs: 'FakeFilesystem') -> None: + """Test filtering when no .cycodeignore file exists.""" + # Create structure without .cycodeignore + fs.create_dir('/home/user/project') + fs.create_file('/home/user/project/file1.py') + fs.create_file('/home/user/project/file2.log') + + repo_path = normpath('/home/user/project') + documents = [ + Document(path='file1.py', content='code'), + Document(path='file2.log', content='log'), + ] + + result = filter_documents_with_cycodeignore(documents, repo_path, is_cycodeignore_allowed=True) + + # Should return all documents since no .cycodeignore exists + assert len(result) == 2 + assert result == documents + + +def test_filter_documents_with_cycodeignore_basic_filtering(fs: 'FakeFilesystem') -> None: + """Test basic document filtering with .cycodeignore.""" + _create_mocked_file_structure(fs) + + repo_path = normpath('/home/user/project') + documents = _create_test_documents(repo_path) + + result = filter_documents_with_cycodeignore(documents, repo_path, is_cycodeignore_allowed=True) + + # Count expected results: should exclude *.pyc, *.log, and build/* files + expected_files = { + 'presented.txt', + 'presented.py', + 'src/main.py', + } + + result_files = {doc.path for doc in result} + assert result_files == expected_files + + # Verify specific exclusions + excluded_files = {doc.path for doc in documents if doc not in result} + assert 'ignored.pyc' in excluded_files + assert 'ignored.log' in excluded_files + assert 'src/debug.log' in excluded_files + assert 'src/temp.pyc' in excluded_files + assert 'build/output.js' in excluded_files + assert 'build/bundle.css' in excluded_files + + +def test_filter_documents_with_cycodeignore_relative_paths(fs: 'FakeFilesystem') -> None: + """Test filtering documents with relative paths (no absolute_path set).""" + _create_mocked_file_structure(fs) + + repo_path = normpath('/home/user/project') + + # Create documents without absolute_path + documents = [ + Document(path='presented.py', content='code'), + Document(path='ignored.pyc', content='compiled'), + Document(path='src/main.py', content='code'), + Document(path='src/debug.log', content='log'), + ] + + result = filter_documents_with_cycodeignore(documents, repo_path, is_cycodeignore_allowed=True) + + # Should filter out .pyc and .log files + expected_files = {'presented.py', 'src/main.py'} + result_files = {doc.path for doc in result} + assert result_files == expected_files + + +def test_filter_documents_with_cycodeignore_absolute_paths(fs: 'FakeFilesystem') -> None: + """Test filtering documents with absolute paths in path field.""" + _create_mocked_file_structure(fs) + + repo_path = normpath('/home/user/project') + + # Create documents with absolute paths in path field + documents = [ + Document(path=normpath('/home/user/project/presented.py'), content='code'), + Document(path=normpath('/home/user/project/ignored.pyc'), content='compiled'), + Document(path=normpath('/home/user/project/src/main.py'), content='code'), + Document(path=normpath('/home/user/project/src/debug.log'), content='log'), + ] + + result = filter_documents_with_cycodeignore(documents, repo_path, is_cycodeignore_allowed=True) + + # Should filter out .pyc and .log files + expected_files = {normpath('/home/user/project/presented.py'), normpath('/home/user/project/src/main.py')} + result_files = {doc.path for doc in result} + assert result_files == expected_files + + +def test_filter_documents_with_cycodeignore_empty_file(fs: 'FakeFilesystem') -> None: + """Test filtering with empty .cycodeignore file.""" + fs.create_dir('/home/user/project') + fs.create_file('/home/user/project/.cycodeignore', contents='') # empty file + fs.create_file('/home/user/project/file1.py') + fs.create_file('/home/user/project/file2.log') + + repo_path = normpath('/home/user/project') + documents = [ + Document(path='file1.py', content='code'), + Document(path='file2.log', content='log'), + ] + + result = filter_documents_with_cycodeignore(documents, repo_path, is_cycodeignore_allowed=True) + + # Should return all documents since .cycodeignore is empty + assert len(result) == 2 + + +def test_filter_documents_with_cycodeignore_comments_only(fs: 'FakeFilesystem') -> None: + """Test filtering with .cycodeignore file containing only comments and empty lines.""" + fs.create_dir('/home/user/project') + fs.create_file('/home/user/project/.cycodeignore', contents='# Just comments\n\n# More comments\n') + fs.create_file('/home/user/project/file1.py') + fs.create_file('/home/user/project/file2.log') + + repo_path = normpath('/home/user/project') + documents = [ + Document(path='file1.py', content='code'), + Document(path='file2.log', content='log'), + ] + + result = filter_documents_with_cycodeignore(documents, repo_path, is_cycodeignore_allowed=True) + + # Should return all documents since no real ignore patterns + assert len(result) == 2 + + +def test_filter_documents_with_cycodeignore_error_handling() -> None: + """Test error handling when document processing fails.""" + # Use non-existent repo path + repo_path = normpath('/non/existent/path') + + documents = [ + Document(path='file1.py', content='code'), + Document(path='file2.txt', content='content'), + ] + + # Should return all documents since .cycodeignore doesn't exist + result = filter_documents_with_cycodeignore(documents, repo_path, is_cycodeignore_allowed=True) + assert len(result) == 2 + + +def test_filter_documents_with_cycodeignore_complex_patterns(fs: 'FakeFilesystem') -> None: + """Test filtering with complex ignore patterns.""" + fs.create_dir('/home/user/project') + + # Create .cycodeignore with various pattern types + cycodeignore_content = """ +# Ignore specific files +config.json +secrets.key + +# Ignore file patterns +*.tmp +*.cache + +# Ignore directories +logs/ +temp/ + +# Ignore files in specific directories +tests/*.pyc +""" + fs.create_file('/home/user/project/.cycodeignore', contents=cycodeignore_content) + + # Create test files + test_files = [ + 'config.json', # ignored + 'secrets.key', # ignored + 'app.py', # allowed + 'file.tmp', # ignored + 'data.cache', # ignored + 'logs/app.log', # ignored (directory) + 'temp/file.txt', # ignored (directory) + 'tests/test.pyc', # ignored (pattern in directory) + 'tests/test.py', # allowed + 'src/main.py', # allowed + ] + + for file_path in test_files: + full_path = normpath(os.path.join('/home/user/project', file_path)) + os.makedirs(os.path.dirname(full_path), exist_ok=True) + fs.create_file(full_path) + + repo_path = normpath('/home/user/project') + documents = [Document(path=f, content='content') for f in test_files] + + result = filter_documents_with_cycodeignore(documents, repo_path, is_cycodeignore_allowed=True) + + # Should only allow: app.py, tests/test.py, src/main.py + expected_files = {'app.py', 'tests/test.py', 'src/main.py'} + result_files = {doc.path for doc in result} + assert result_files == expected_files + + +def test_filter_documents_with_cycodeignore_not_allowed(fs: 'FakeFilesystem') -> None: + """Test that filtering is skipped when is_cycodeignore_allowed is False.""" + _create_mocked_file_structure(fs) + + repo_path = normpath('/home/user/project') + documents = _create_test_documents(repo_path) + + # With filtering disabled, should return all documents + result = filter_documents_with_cycodeignore(documents, repo_path, is_cycodeignore_allowed=False) + + # Should return all documents without filtering + assert len(result) == len(documents) + assert {doc.path for doc in result} == {doc.path for doc in documents} + + # Verify that files that would normally be filtered are still present + result_files = {doc.path for doc in result} + assert 'ignored.pyc' in result_files + assert 'ignored.log' in result_files + assert 'build/output.js' in result_files + assert 'src/debug.log' in result_files From 74d97132929a3977ef15866eeba830fcbbbb9e52 Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Fri, 10 Oct 2025 14:54:16 +0100 Subject: [PATCH 208/257] CM-53811: unified commit range parse (#353) --- cycode/cli/apps/scan/commit_range_scanner.py | 7 +- .../files_collector/commit_range_documents.py | 17 +---- .../test_commit_range_documents.py | 72 ++++++++++++++++++- 3 files changed, 73 insertions(+), 23 deletions(-) diff --git a/cycode/cli/apps/scan/commit_range_scanner.py b/cycode/cli/apps/scan/commit_range_scanner.py index 335531c2..eb0296c8 100644 --- a/cycode/cli/apps/scan/commit_range_scanner.py +++ b/cycode/cli/apps/scan/commit_range_scanner.py @@ -26,8 +26,7 @@ get_diff_file_path, get_pre_commit_modified_documents, get_safe_head_reference_for_diff, - parse_commit_range_sast, - parse_commit_range_sca, + parse_commit_range, ) from cycode.cli.files_collector.documents_walk_ignore import filter_documents_with_cycodeignore from cycode.cli.files_collector.file_excluder import excluder @@ -187,7 +186,7 @@ def _scan_commit_range_documents( def _scan_sca_commit_range(ctx: typer.Context, repo_path: str, commit_range: str, **_) -> None: scan_parameters = get_scan_parameters(ctx, (repo_path,)) - from_commit_rev, to_commit_rev = parse_commit_range_sca(commit_range, repo_path) + from_commit_rev, to_commit_rev = parse_commit_range(commit_range, repo_path) from_commit_documents, to_commit_documents, _ = get_commit_range_modified_documents( ctx.obj['progress_bar'], ScanProgressBarSection.PREPARE_LOCAL_FILES, repo_path, from_commit_rev, to_commit_rev ) @@ -228,7 +227,7 @@ def _scan_secret_commit_range( def _scan_sast_commit_range(ctx: typer.Context, repo_path: str, commit_range: str, **_) -> None: scan_parameters = get_scan_parameters(ctx, (repo_path,)) - from_commit_rev, to_commit_rev = parse_commit_range_sast(commit_range, repo_path) + from_commit_rev, to_commit_rev = parse_commit_range(commit_range, repo_path) _, commit_documents, diff_documents = get_commit_range_modified_documents( ctx.obj['progress_bar'], ScanProgressBarSection.PREPARE_LOCAL_FILES, diff --git a/cycode/cli/files_collector/commit_range_documents.py b/cycode/cli/files_collector/commit_range_documents.py index 3d527498..8f8eccac 100644 --- a/cycode/cli/files_collector/commit_range_documents.py +++ b/cycode/cli/files_collector/commit_range_documents.py @@ -408,22 +408,7 @@ def get_pre_commit_modified_documents( return git_head_documents, pre_committed_documents, diff_documents -def parse_commit_range_sca(commit_range: str, path: str) -> tuple[Optional[str], Optional[str]]: - # FIXME(MarshalX): i truly believe that this function does NOT work as expected - # it does not handle cases like 'A..B' correctly - # i leave it as it for SCA to not break anything - # the more correct approach is implemented for SAST - from_commit_rev = to_commit_rev = None - - for commit in git_proxy.get_repo(path).iter_commits(rev=commit_range): - if not to_commit_rev: - to_commit_rev = commit.hexsha - from_commit_rev = commit.hexsha - - return from_commit_rev, to_commit_rev - - -def parse_commit_range_sast(commit_range: str, path: str) -> tuple[Optional[str], Optional[str]]: +def parse_commit_range(commit_range: str, path: str) -> tuple[Optional[str], Optional[str]]: """Parses a git commit range string and returns the full SHAs for the 'from' and 'to' commits. Supports: diff --git a/tests/cli/files_collector/test_commit_range_documents.py b/tests/cli/files_collector/test_commit_range_documents.py index 568b1bec..cd30d1eb 100644 --- a/tests/cli/files_collector/test_commit_range_documents.py +++ b/tests/cli/files_collector/test_commit_range_documents.py @@ -14,6 +14,7 @@ calculate_pre_push_commit_range, get_diff_file_path, get_safe_head_reference_for_diff, + parse_commit_range, parse_pre_push_input, ) from cycode.cli.utils.path_utils import get_path_by_os @@ -22,7 +23,8 @@ @contextmanager def git_repository(path: str) -> Generator[Repo, None, None]: """Context manager for Git repositories that ensures proper cleanup on Windows.""" - repo = Repo.init(path) + # Ensure the initialized repository uses 'main' as the default branch + repo = Repo.init(path, b='main') try: yield repo finally: @@ -539,8 +541,8 @@ def test_calculate_range_for_new_branch_with_merge_base(self) -> None: repo.index.add(['feature.py']) feature_commit = repo.index.commit('Add feature') - # Switch back to master to simulate we're pushing a feature branch - repo.heads.master.checkout() + # Switch back to the default branch to simulate pushing the feature branch + repo.heads.main.checkout() # Test new branch push push_details = f'refs/heads/feature {feature_commit.hexsha} refs/heads/feature {consts.EMPTY_COMMIT_SHA}' @@ -805,3 +807,67 @@ def test_simulate_pre_push_hook_input_format(self) -> None: parts = push_input.split() expected_range = f'{parts[3]}..{parts[1]}' assert commit_range == expected_range + + +class TestParseCommitRange: + """Tests to validate unified parse_commit_range behavior matches git semantics.""" + + def _make_linear_history(self, repo: Repo, base_dir: str) -> tuple[str, str, str]: + """Create three linear commits A -> B -> C and return their SHAs.""" + a_file = os.path.join(base_dir, 'a.txt') + with open(a_file, 'w') as f: + f.write('A') + repo.index.add(['a.txt']) + a = repo.index.commit('A') + + with open(a_file, 'a') as f: + f.write('B') + repo.index.add(['a.txt']) + b = repo.index.commit('B') + + with open(a_file, 'a') as f: + f.write('C') + repo.index.add(['a.txt']) + c = repo.index.commit('C') + + return a.hexsha, b.hexsha, c.hexsha + + def test_two_dot_linear_history(self) -> None: + """For 'A..C', expect (A,C) in linear history.""" + with temporary_git_repository() as (temp_dir, repo): + a, b, c = self._make_linear_history(repo, temp_dir) + + parsed_from, parsed_to = parse_commit_range(f'{a}..{c}', temp_dir) + assert (parsed_from, parsed_to) == (a, c) + + def test_three_dot_linear_history(self) -> None: + """For 'A...C' in linear history, expect (A,C).""" + with temporary_git_repository() as (temp_dir, repo): + a, b, c = self._make_linear_history(repo, temp_dir) + + parsed_from, parsed_to = parse_commit_range(f'{a}...{c}', temp_dir) + assert (parsed_from, parsed_to) == (a, c) + + def test_open_right_linear_history(self) -> None: + """For 'A..', expect (A,HEAD=C).""" + with temporary_git_repository() as (temp_dir, repo): + a, b, c = self._make_linear_history(repo, temp_dir) + + parsed_from, parsed_to = parse_commit_range(f'{a}..', temp_dir) + assert (parsed_from, parsed_to) == (a, c) + + def test_open_left_linear_history(self) -> None: + """For '..C' where HEAD==C, expect (HEAD=C,C).""" + with temporary_git_repository() as (temp_dir, repo): + a, b, c = self._make_linear_history(repo, temp_dir) + + parsed_from, parsed_to = parse_commit_range(f'..{c}', temp_dir) + assert (parsed_from, parsed_to) == (c, c) + + def test_single_commit_spec(self) -> None: + """For 'A', expect (A,HEAD=C).""" + with temporary_git_repository() as (temp_dir, repo): + a, b, c = self._make_linear_history(repo, temp_dir) + + parsed_from, parsed_to = parse_commit_range(a, temp_dir) + assert (parsed_from, parsed_to) == (a, c) From 0b79e1537ed434df9aa961fa3a42990b8b1b89d8 Mon Sep 17 00:00:00 2001 From: Branch Vincent Date: Wed, 22 Oct 2025 01:44:55 -0700 Subject: [PATCH 209/257] Allow python 3.14 (#354) --- .github/workflows/build_executable.yml | 4 +- .github/workflows/docker-image.yml | 4 +- .github/workflows/pre_release.yml | 4 +- .github/workflows/release.yml | 4 +- .github/workflows/ruff.yml | 4 +- .github/workflows/tests.yml | 4 +- .github/workflows/tests_full.yml | 6 +- Dockerfile | 2 +- poetry.lock | 1194 +++++++++++++----------- pyproject.toml | 24 +- 10 files changed, 668 insertions(+), 582 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 87979685..769449c1 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -65,13 +65,13 @@ jobs: uses: actions/cache@v3 with: path: ~/.local - key: poetry-${{ matrix.os }}-1 # increment to reset cache + key: poetry-${{ matrix.os }}-2 # increment to reset cache - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' uses: snok/install-poetry@v1 with: - version: 1.8.3 + version: 2.2.1 - name: Add Poetry to PATH run: echo "$HOME/.local/bin" >> $GITHUB_PATH diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 8a6809b8..ae668a3a 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -37,13 +37,13 @@ jobs: uses: actions/cache@v4 with: path: ~/.local - key: poetry-ubuntu-0 # increment to reset cache + key: poetry-ubuntu-1 # increment to reset cache - name: Setup Poetry if: steps.cached_poetry.outputs.cache-hit != 'true' uses: snok/install-poetry@v1 with: - version: 1.8.3 + version: 2.2.1 - name: Add Poetry to PATH run: echo "$HOME/.local/bin" >> $GITHUB_PATH diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 9b665d73..8847499a 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -42,13 +42,13 @@ jobs: uses: actions/cache@v3 with: path: ~/.local - key: poetry-ubuntu-0 # increment to reset cache + key: poetry-ubuntu-1 # increment to reset cache - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' uses: snok/install-poetry@v1 with: - version: 1.8.3 + version: 2.2.1 - name: Add Poetry to PATH run: echo "$HOME/.local/bin" >> $GITHUB_PATH diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aacbae5e..14ddbe77 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,13 +41,13 @@ jobs: uses: actions/cache@v3 with: path: ~/.local - key: poetry-ubuntu-0 # increment to reset cache + key: poetry-ubuntu-1 # increment to reset cache - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' uses: snok/install-poetry@v1 with: - version: 1.8.3 + version: 2.2.1 - name: Add Poetry to PATH run: echo "$HOME/.local/bin" >> $GITHUB_PATH diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 575abfd0..eb32b58e 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -30,13 +30,13 @@ jobs: uses: actions/cache@v3 with: path: ~/.local - key: poetry-ubuntu-0 # increment to reset cache + key: poetry-ubuntu-1 # increment to reset cache - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' uses: snok/install-poetry@v1 with: - version: 1.8.3 + version: 2.2.1 - name: Add Poetry to PATH run: echo "$HOME/.local/bin" >> $GITHUB_PATH diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a62a01b5..e2ebf709 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -35,13 +35,13 @@ jobs: uses: actions/cache@v3 with: path: ~/.local - key: poetry-ubuntu-0 # increment to reset cache + key: poetry-ubuntu-1 # increment to reset cache - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' uses: snok/install-poetry@v1 with: - version: 1.8.3 + version: 2.2.1 - name: Add Poetry to PATH run: echo "$HOME/.local/bin" >> $GITHUB_PATH diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index a9ddd4f6..b8d1fc2c 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: os: [ macos-latest, ubuntu-latest, windows-latest ] - python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13" ] + python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13", "3.14" ] runs-on: ${{matrix.os}} @@ -50,13 +50,13 @@ jobs: uses: actions/cache@v3 with: path: ~/.local - key: poetry-${{ matrix.os }}-${{ matrix.python-version }}-2 # increment to reset cache + key: poetry-${{ matrix.os }}-${{ matrix.python-version }}-3 # increment to reset cache - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' uses: snok/install-poetry@v1 with: - version: 1.8.3 + version: 2.2.1 - name: Add Poetry to PATH run: echo "$HOME/.local/bin" >> $GITHUB_PATH diff --git a/Dockerfile b/Dockerfile index 034a1d96..40d6fad3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ WORKDIR /usr/cycode/app RUN apk add git=2.47.3-r0 FROM base AS builder -ENV POETRY_VERSION=1.8.3 +ENV POETRY_VERSION=2.2.1 # deps are required to build cffi RUN apk add --no-cache --virtual .build-deps gcc=14.2.0-r4 libffi-dev=3.4.7-r0 musl-dev=1.2.5-r9 && \ diff --git a/poetry.lock b/poetry.lock index 54f09b5d..3f5f9388 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "altgraph" @@ -27,14 +27,15 @@ files = [ [[package]] name = "anyio" -version = "4.9.0" -description = "High level compatibility layer for multiple asynchronous event loop implementations" +version = "4.11.0" +description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main"] +markers = "python_version >= \"3.10\"" files = [ - {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, - {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, + {file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"}, + {file = "anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4"}, ] [package.dependencies] @@ -44,9 +45,7 @@ sniffio = ">=1.1" typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] -doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] -test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] -trio = ["trio (>=0.26.1)"] +trio = ["trio (>=0.31.0)"] [[package]] name = "arrow" @@ -70,25 +69,17 @@ test = ["dateparser (==1.*)", "pre-commit", "pytest", "pytest-cov", "pytest-mock [[package]] name = "attrs" -version = "25.3.0" +version = "25.4.0" description = "Classes Without Boilerplate" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"}, - {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"}, + {file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"}, + {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, ] -[package.extras] -benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier"] -tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] - [[package]] name = "binaryornot" version = "0.4.4" @@ -106,14 +97,14 @@ chardet = ">=3.0.2" [[package]] name = "certifi" -version = "2025.4.26" +version = "2025.10.5" description = "Python package for providing Mozilla's CA Bundle." optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" groups = ["main", "test"] files = [ - {file = "certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3"}, - {file = "certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6"}, + {file = "certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de"}, + {file = "certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"}, ] [[package]] @@ -130,104 +121,125 @@ files = [ [[package]] name = "charset-normalizer" -version = "3.4.1" +version = "3.4.4" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" groups = ["main", "test"] files = [ - {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, - {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, - {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"}, + {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, + {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, ] [[package]] @@ -347,16 +359,19 @@ packaging = ">=20.9" [[package]] name = "exceptiongroup" -version = "1.2.2" +version = "1.3.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["main", "dev", "test"] -markers = "python_version < \"3.11\"" +groups = ["main", "test"] files = [ - {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, - {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, + {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, + {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, ] +markers = {main = "python_version == \"3.10\"", test = "python_version < \"3.11\""} + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} [package.extras] test = ["pytest (>=6)"] @@ -378,18 +393,19 @@ smmap = ">=3.0.1,<6" [[package]] name = "gitpython" -version = "3.1.44" +version = "3.1.45" description = "GitPython is a Python library used to interact with Git repositories" optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "GitPython-3.1.44-py3-none-any.whl", hash = "sha256:9e0e10cda9bed1ee64bc9a6de50e7e38a9c9943241cd7f585f6df3ed28011110"}, - {file = "gitpython-3.1.44.tar.gz", hash = "sha256:c87e30b26253bf5418b01b0660f818967f3c503193838337fe5e573331249269"}, + {file = "gitpython-3.1.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77"}, + {file = "gitpython-3.1.45.tar.gz", hash = "sha256:85b0ee964ceddf211c41b9f27a49086010a190fd8132a24e21f362a4b36a791c"}, ] [package.dependencies] gitdb = ">=4.0.1,<5" +typing-extensions = {version = ">=3.10.0.2", markers = "python_version < \"3.10\""} [package.extras] doc = ["sphinx (>=7.1.2,<7.2)", "sphinx-autodoc-typehints", "sphinx_rtd_theme"] @@ -459,27 +475,27 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "httpx-sse" -version = "0.4.0" +version = "0.4.3" description = "Consume Server-Sent Event (SSE) messages with HTTPX." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721"}, - {file = "httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f"}, + {file = "httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc"}, + {file = "httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d"}, ] [[package]] name = "idna" -version = "3.10" +version = "3.11" description = "Internationalized Domain Names in Applications (IDNA)" optional = false -python-versions = ">=3.6" -groups = ["main", "dev", "test"] +python-versions = ">=3.8" +groups = ["main", "test"] files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, + {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, + {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, ] [package.extras] @@ -524,15 +540,15 @@ files = [ [[package]] name = "jsonschema" -version = "4.24.0" +version = "4.25.1" description = "An implementation of JSON Schema validation for Python" optional = false python-versions = ">=3.9" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "jsonschema-4.24.0-py3-none-any.whl", hash = "sha256:a462455f19f5faf404a7902952b6f0e3ce868f3ee09a359b05eca6673bd8412d"}, - {file = "jsonschema-4.24.0.tar.gz", hash = "sha256:0b4e8069eb12aedfa881333004bccaec24ecef5a8a6a4b6df142b2cc9599d196"}, + {file = "jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63"}, + {file = "jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85"}, ] [package.dependencies] @@ -543,19 +559,19 @@ rpds-py = ">=0.7.1" [package.extras] format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] -format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=24.6.0)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "rfc3987-syntax (>=1.1.0)", "uri-template", "webcolors (>=24.6.0)"] [[package]] name = "jsonschema-specifications" -version = "2025.4.1" +version = "2025.9.1" description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" optional = false python-versions = ">=3.9" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af"}, - {file = "jsonschema_specifications-2025.4.1.tar.gz", hash = "sha256:630159c9f4dbea161a6a2205c3011cc4f18ff381b189fff48bb39b9bf26ae608"}, + {file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"}, + {file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"}, ] [package.dependencies] @@ -624,29 +640,29 @@ tests = ["pytest", "pytz", "simplejson"] [[package]] name = "mcp" -version = "1.11.0" +version = "1.18.0" description = "Model Context Protocol SDK" optional = false python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "mcp-1.11.0-py3-none-any.whl", hash = "sha256:58deac37f7483e4b338524b98bc949b7c2b7c33d978f5fafab5bde041c5e2595"}, - {file = "mcp-1.11.0.tar.gz", hash = "sha256:49a213df56bb9472ff83b3132a4825f5c8f5b120a90246f08b0dac6bedac44c8"}, + {file = "mcp-1.18.0-py3-none-any.whl", hash = "sha256:42f10c270de18e7892fdf9da259029120b1ea23964ff688248c69db9d72b1d0a"}, + {file = "mcp-1.18.0.tar.gz", hash = "sha256:aa278c44b1efc0a297f53b68df865b988e52dd08182d702019edcf33a8e109f6"}, ] [package.dependencies] anyio = ">=4.5" -httpx = ">=0.27" +httpx = ">=0.27.1" httpx-sse = ">=0.4" jsonschema = ">=4.20.0" -pydantic = ">=2.8.0,<3.0.0" +pydantic = ">=2.11.0,<3.0.0" pydantic-settings = ">=2.5.2" python-multipart = ">=0.0.9" pywin32 = {version = ">=310", markers = "sys_platform == \"win32\""} sse-starlette = ">=1.6.1" starlette = ">=0.27" -uvicorn = {version = ">=0.23.1", markers = "sys_platform != \"emscripten\""} +uvicorn = {version = ">=0.31.1", markers = "sys_platform != \"emscripten\""} [package.extras] cli = ["python-dotenv (>=1.0.0)", "typer (>=0.16.0)"] @@ -737,37 +753,37 @@ files = [ [[package]] name = "pluggy" -version = "1.5.0" +version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["test"] files = [ - {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, - {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, ] [package.extras] dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] +testing = ["coverage", "pytest", "pytest-benchmark"] [[package]] name = "pydantic" -version = "2.11.5" +version = "2.12.3" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pydantic-2.11.5-py3-none-any.whl", hash = "sha256:f9c26ba06f9747749ca1e5c94d6a85cb84254577553c8785576fd38fa64dc0f7"}, - {file = "pydantic-2.11.5.tar.gz", hash = "sha256:7f853db3d0ce78ce8bbb148c401c2cdd6431b3473c0cdff2755c7690952a7b7a"}, + {file = "pydantic-2.12.3-py3-none-any.whl", hash = "sha256:6986454a854bc3bc6e5443e1369e06a3a456af9d339eda45510f517d9ea5c6bf"}, + {file = "pydantic-2.12.3.tar.gz", hash = "sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74"}, ] [package.dependencies] annotated-types = ">=0.6.0" -pydantic-core = "2.33.2" -typing-extensions = ">=4.12.2" -typing-inspection = ">=0.4.0" +pydantic-core = "2.41.4" +typing-extensions = ">=4.14.1" +typing-inspection = ">=0.4.2" [package.extras] email = ["email-validator (>=2.0.0)"] @@ -775,127 +791,145 @@ timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows [[package]] name = "pydantic-core" -version = "2.33.2" +version = "2.41.4" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, - {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, - {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, - {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, - {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, - {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, - {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, - {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, - {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, - {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, - {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, - {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, - {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, + {file = "pydantic_core-2.41.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2442d9a4d38f3411f22eb9dd0912b7cbf4b7d5b6c92c4173b75d3e1ccd84e36e"}, + {file = "pydantic_core-2.41.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:30a9876226dda131a741afeab2702e2d127209bde3c65a2b8133f428bc5d006b"}, + {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d55bbac04711e2980645af68b97d445cdbcce70e5216de444a6c4b6943ebcccd"}, + {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e1d778fb7849a42d0ee5927ab0f7453bf9f85eef8887a546ec87db5ddb178945"}, + {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b65077a4693a98b90ec5ad8f203ad65802a1b9b6d4a7e48066925a7e1606706"}, + {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62637c769dee16eddb7686bf421be48dfc2fae93832c25e25bc7242e698361ba"}, + {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dfe3aa529c8f501babf6e502936b9e8d4698502b2cfab41e17a028d91b1ac7b"}, + {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ca2322da745bf2eeb581fc9ea3bbb31147702163ccbcbf12a3bb630e4bf05e1d"}, + {file = "pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e8cd3577c796be7231dcf80badcf2e0835a46665eaafd8ace124d886bab4d700"}, + {file = "pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:1cae8851e174c83633f0833e90636832857297900133705ee158cf79d40f03e6"}, + {file = "pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a26d950449aae348afe1ac8be5525a00ae4235309b729ad4d3399623125b43c9"}, + {file = "pydantic_core-2.41.4-cp310-cp310-win32.whl", hash = "sha256:0cf2a1f599efe57fa0051312774280ee0f650e11152325e41dfd3018ef2c1b57"}, + {file = "pydantic_core-2.41.4-cp310-cp310-win_amd64.whl", hash = "sha256:a8c2e340d7e454dc3340d3d2e8f23558ebe78c98aa8f68851b04dcb7bc37abdc"}, + {file = "pydantic_core-2.41.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:28ff11666443a1a8cf2a044d6a545ebffa8382b5f7973f22c36109205e65dc80"}, + {file = "pydantic_core-2.41.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61760c3925d4633290292bad462e0f737b840508b4f722247d8729684f6539ae"}, + {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eae547b7315d055b0de2ec3965643b0ab82ad0106a7ffd29615ee9f266a02827"}, + {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ef9ee5471edd58d1fcce1c80ffc8783a650e3e3a193fe90d52e43bb4d87bff1f"}, + {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15dd504af121caaf2c95cb90c0ebf71603c53de98305621b94da0f967e572def"}, + {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a926768ea49a8af4d36abd6a8968b8790f7f76dd7cbd5a4c180db2b4ac9a3a2"}, + {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6916b9b7d134bff5440098a4deb80e4cb623e68974a87883299de9124126c2a8"}, + {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5cf90535979089df02e6f17ffd076f07237efa55b7343d98760bde8743c4b265"}, + {file = "pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7533c76fa647fade2d7ec75ac5cc079ab3f34879626dae5689b27790a6cf5a5c"}, + {file = "pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:37e516bca9264cbf29612539801ca3cd5d1be465f940417b002905e6ed79d38a"}, + {file = "pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0c19cb355224037c83642429b8ce261ae108e1c5fbf5c028bac63c77b0f8646e"}, + {file = "pydantic_core-2.41.4-cp311-cp311-win32.whl", hash = "sha256:09c2a60e55b357284b5f31f5ab275ba9f7f70b7525e18a132ec1f9160b4f1f03"}, + {file = "pydantic_core-2.41.4-cp311-cp311-win_amd64.whl", hash = "sha256:711156b6afb5cb1cb7c14a2cc2c4a8b4c717b69046f13c6b332d8a0a8f41ca3e"}, + {file = "pydantic_core-2.41.4-cp311-cp311-win_arm64.whl", hash = "sha256:6cb9cf7e761f4f8a8589a45e49ed3c0d92d1d696a45a6feaee8c904b26efc2db"}, + {file = "pydantic_core-2.41.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ab06d77e053d660a6faaf04894446df7b0a7e7aba70c2797465a0a1af00fc887"}, + {file = "pydantic_core-2.41.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c53ff33e603a9c1179a9364b0a24694f183717b2e0da2b5ad43c316c956901b2"}, + {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:304c54176af2c143bd181d82e77c15c41cbacea8872a2225dd37e6544dce9999"}, + {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:025ba34a4cf4fb32f917d5d188ab5e702223d3ba603be4d8aca2f82bede432a4"}, + {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9f5f30c402ed58f90c70e12eff65547d3ab74685ffe8283c719e6bead8ef53f"}, + {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd96e5d15385d301733113bcaa324c8bcf111275b7675a9c6e88bfb19fc05e3b"}, + {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98f348cbb44fae6e9653c1055db7e29de67ea6a9ca03a5fa2c2e11a47cff0e47"}, + {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec22626a2d14620a83ca583c6f5a4080fa3155282718b6055c2ea48d3ef35970"}, + {file = "pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a95d4590b1f1a43bf33ca6d647b990a88f4a3824a8c4572c708f0b45a5290ed"}, + {file = "pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:f9672ab4d398e1b602feadcffcdd3af44d5f5e6ddc15bc7d15d376d47e8e19f8"}, + {file = "pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:84d8854db5f55fead3b579f04bda9a36461dab0730c5d570e1526483e7bb8431"}, + {file = "pydantic_core-2.41.4-cp312-cp312-win32.whl", hash = "sha256:9be1c01adb2ecc4e464392c36d17f97e9110fbbc906bcbe1c943b5b87a74aabd"}, + {file = "pydantic_core-2.41.4-cp312-cp312-win_amd64.whl", hash = "sha256:d682cf1d22bab22a5be08539dca3d1593488a99998f9f412137bc323179067ff"}, + {file = "pydantic_core-2.41.4-cp312-cp312-win_arm64.whl", hash = "sha256:833eebfd75a26d17470b58768c1834dfc90141b7afc6eb0429c21fc5a21dcfb8"}, + {file = "pydantic_core-2.41.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:85e050ad9e5f6fe1004eec65c914332e52f429bc0ae12d6fa2092407a462c746"}, + {file = "pydantic_core-2.41.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7393f1d64792763a48924ba31d1e44c2cfbc05e3b1c2c9abb4ceeadd912cced"}, + {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94dab0940b0d1fb28bcab847adf887c66a27a40291eedf0b473be58761c9799a"}, + {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de7c42f897e689ee6f9e93c4bec72b99ae3b32a2ade1c7e4798e690ff5246e02"}, + {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:664b3199193262277b8b3cd1e754fb07f2c6023289c815a1e1e8fb415cb247b1"}, + {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d95b253b88f7d308b1c0b417c4624f44553ba4762816f94e6986819b9c273fb2"}, + {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1351f5bbdbbabc689727cb91649a00cb9ee7203e0a6e54e9f5ba9e22e384b84"}, + {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1affa4798520b148d7182da0615d648e752de4ab1a9566b7471bc803d88a062d"}, + {file = "pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7b74e18052fea4aa8dea2fb7dbc23d15439695da6cbe6cfc1b694af1115df09d"}, + {file = "pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:285b643d75c0e30abda9dc1077395624f314a37e3c09ca402d4015ef5979f1a2"}, + {file = "pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f52679ff4218d713b3b33f88c89ccbf3a5c2c12ba665fb80ccc4192b4608dbab"}, + {file = "pydantic_core-2.41.4-cp313-cp313-win32.whl", hash = "sha256:ecde6dedd6fff127c273c76821bb754d793be1024bc33314a120f83a3c69460c"}, + {file = "pydantic_core-2.41.4-cp313-cp313-win_amd64.whl", hash = "sha256:d081a1f3800f05409ed868ebb2d74ac39dd0c1ff6c035b5162356d76030736d4"}, + {file = "pydantic_core-2.41.4-cp313-cp313-win_arm64.whl", hash = "sha256:f8e49c9c364a7edcbe2a310f12733aad95b022495ef2a8d653f645e5d20c1564"}, + {file = "pydantic_core-2.41.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ed97fd56a561f5eb5706cebe94f1ad7c13b84d98312a05546f2ad036bafe87f4"}, + {file = "pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a870c307bf1ee91fc58a9a61338ff780d01bfae45922624816878dce784095d2"}, + {file = "pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d25e97bc1f5f8f7985bdc2335ef9e73843bb561eb1fa6831fdfc295c1c2061cf"}, + {file = "pydantic_core-2.41.4-cp313-cp313t-win_amd64.whl", hash = "sha256:d405d14bea042f166512add3091c1af40437c2e7f86988f3915fabd27b1e9cd2"}, + {file = "pydantic_core-2.41.4-cp313-cp313t-win_arm64.whl", hash = "sha256:19f3684868309db5263a11bace3c45d93f6f24afa2ffe75a647583df22a2ff89"}, + {file = "pydantic_core-2.41.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1"}, + {file = "pydantic_core-2.41.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac"}, + {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554"}, + {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e"}, + {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616"}, + {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af"}, + {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12"}, + {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d"}, + {file = "pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad"}, + {file = "pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a"}, + {file = "pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025"}, + {file = "pydantic_core-2.41.4-cp314-cp314-win32.whl", hash = "sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e"}, + {file = "pydantic_core-2.41.4-cp314-cp314-win_amd64.whl", hash = "sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894"}, + {file = "pydantic_core-2.41.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d"}, + {file = "pydantic_core-2.41.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da"}, + {file = "pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e"}, + {file = "pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa"}, + {file = "pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d"}, + {file = "pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0"}, + {file = "pydantic_core-2.41.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:646e76293345954acea6966149683047b7b2ace793011922208c8e9da12b0062"}, + {file = "pydantic_core-2.41.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cc8e85a63085a137d286e2791037f5fdfff0aabb8b899483ca9c496dd5797338"}, + {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:692c622c8f859a17c156492783902d8370ac7e121a611bd6fe92cc71acf9ee8d"}, + {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d1e2906efb1031a532600679b424ef1d95d9f9fb507f813951f23320903adbd7"}, + {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e04e2f7f8916ad3ddd417a7abdd295276a0bf216993d9318a5d61cc058209166"}, + {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df649916b81822543d1c8e0e1d079235f68acdc7d270c911e8425045a8cfc57e"}, + {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66c529f862fdba70558061bb936fe00ddbaaa0c647fd26e4a4356ef1d6561891"}, + {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3b4c5a1fd3a311563ed866c2c9b62da06cb6398bee186484ce95c820db71cb"}, + {file = "pydantic_core-2.41.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6e0fc40d84448f941df9b3334c4b78fe42f36e3bf631ad54c3047a0cdddc2514"}, + {file = "pydantic_core-2.41.4-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:44e7625332683b6c1c8b980461475cde9595eff94447500e80716db89b0da005"}, + {file = "pydantic_core-2.41.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:170ee6835f6c71081d031ef1c3b4dc4a12b9efa6a9540f93f95b82f3c7571ae8"}, + {file = "pydantic_core-2.41.4-cp39-cp39-win32.whl", hash = "sha256:3adf61415efa6ce977041ba9745183c0e1f637ca849773afa93833e04b163feb"}, + {file = "pydantic_core-2.41.4-cp39-cp39-win_amd64.whl", hash = "sha256:a238dd3feee263eeaeb7dc44aea4ba1364682c4f9f9467e6af5596ba322c2332"}, + {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:a1b2cfec3879afb742a7b0bcfa53e4f22ba96571c9e54d6a3afe1052d17d843b"}, + {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:d175600d975b7c244af6eb9c9041f10059f20b8bbffec9e33fdd5ee3f67cdc42"}, + {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f184d657fa4947ae5ec9c47bd7e917730fa1cbb78195037e32dcbab50aca5ee"}, + {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed810568aeffed3edc78910af32af911c835cc39ebbfacd1f0ab5dd53028e5c"}, + {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:4f5d640aeebb438517150fdeec097739614421900e4a08db4a3ef38898798537"}, + {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a9ab037b71927babc6d9e7fc01aea9e66dc2a4a34dff06ef0724a4049629f94"}, + {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4dab9484ec605c3016df9ad4fd4f9a390bc5d816a3b10c6550f8424bb80b18c"}, + {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8a5028425820731d8c6c098ab642d7b8b999758e24acae03ed38a66eca8335"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:1e5ab4fc177dd41536b3c32b2ea11380dd3d4619a385860621478ac2d25ceb00"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:3d88d0054d3fa11ce936184896bed3c1c5441d6fa483b498fac6a5d0dd6f64a9"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b2a054a8725f05b4b6503357e0ac1c4e8234ad3b0c2ac130d6ffc66f0e170e2"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0d9db5a161c99375a0c68c058e227bee1d89303300802601d76a3d01f74e258"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:6273ea2c8ffdac7b7fda2653c49682db815aebf4a89243a6feccf5e36c18c347"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:4c973add636efc61de22530b2ef83a65f39b6d6f656df97f678720e20de26caa"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b69d1973354758007f46cf2d44a4f3d0933f10b6dc9bf15cf1356e037f6f731a"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:3619320641fd212aaf5997b6ca505e97540b7e16418f4a241f44cdf108ffb50d"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:491535d45cd7ad7e4a2af4a5169b0d07bebf1adfd164b0368da8aa41e19907a5"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:54d86c0cada6aba4ec4c047d0e348cbad7063b87ae0f005d9f8c9ad04d4a92a2"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eca1124aced216b2500dc2609eade086d718e8249cb9696660ab447d50a758bd"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6c9024169becccf0cb470ada03ee578d7348c119a0d42af3dcf9eda96e3a247c"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:26895a4268ae5a2849269f4991cdc97236e4b9c010e51137becf25182daac405"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:ca4df25762cf71308c446e33c9b1fdca2923a3f13de616e2a949f38bf21ff5a8"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:5a28fcedd762349519276c36634e71853b4541079cab4acaaac60c4421827308"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c173ddcd86afd2535e2b695217e82191580663a1d1928239f877f5a1649ef39f"}, + {file = "pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5"}, ] [package.dependencies] -typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +typing-extensions = ">=4.14.1" [[package]] name = "pydantic-settings" -version = "2.9.1" +version = "2.11.0" description = "Settings management using Pydantic" optional = false python-versions = ">=3.9" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef"}, - {file = "pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268"}, + {file = "pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c"}, + {file = "pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180"}, ] [package.dependencies] @@ -924,14 +958,14 @@ files = [ [[package]] name = "pygments" -version = "2.19.1" +version = "2.19.2" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, - {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, ] [package.extras] @@ -974,15 +1008,15 @@ hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"] [[package]] name = "pyinstaller-hooks-contrib" -version = "2025.3" +version = "2025.9" description = "Community maintained hooks for PyInstaller" optional = false python-versions = ">=3.8" groups = ["executable"] markers = "python_version < \"3.13\"" files = [ - {file = "pyinstaller_hooks_contrib-2025.3-py3-none-any.whl", hash = "sha256:70cba46b1a6b82ae9104f074c25926e31f3dde50ff217434d1d660355b949683"}, - {file = "pyinstaller_hooks_contrib-2025.3.tar.gz", hash = "sha256:af129da5cd6219669fbda360e295cc822abac55b7647d03fec63a8fcf0a608cf"}, + {file = "pyinstaller_hooks_contrib-2025.9-py3-none-any.whl", hash = "sha256:ccbfaa49399ef6b18486a165810155e5a8d4c59b41f20dc5da81af7482aaf038"}, + {file = "pyinstaller_hooks_contrib-2025.9.tar.gz", hash = "sha256:56e972bdaad4e9af767ed47d132362d162112260cbe488c9da7fee01f228a5a6"}, ] [package.dependencies] @@ -1066,15 +1100,15 @@ six = ">=1.5" [[package]] name = "python-dotenv" -version = "1.1.0" +version = "1.1.1" description = "Read key-value pairs from a .env file and set them as environment variables" optional = false python-versions = ">=3.9" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d"}, - {file = "python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5"}, + {file = "python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc"}, + {file = "python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab"}, ] [package.extras] @@ -1139,78 +1173,98 @@ files = [ [[package]] name = "pyyaml" -version = "6.0.2" +version = "6.0.3" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" groups = ["main", "test"] files = [ - {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, - {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, - {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, - {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, - {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, - {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, - {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, - {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, - {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, - {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, - {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, - {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, - {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, - {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, - {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, - {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, - {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, + {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, + {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, + {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, + {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, ] [[package]] name = "referencing" -version = "0.36.2" +version = "0.37.0" description = "JSON Referencing + Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0"}, - {file = "referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa"}, + {file = "referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231"}, + {file = "referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8"}, ] [package.dependencies] @@ -1220,14 +1274,14 @@ typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} [[package]] name = "requests" -version = "2.32.4" +version = "2.32.5" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main", "test"] files = [ - {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, - {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, + {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, + {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, ] [package.dependencies] @@ -1283,157 +1337,168 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "rpds-py" -version = "0.26.0" +version = "0.27.1" description = "Python bindings to Rust's persistent data structures (rpds)" optional = false python-versions = ">=3.9" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "rpds_py-0.26.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:4c70c70f9169692b36307a95f3d8c0a9fcd79f7b4a383aad5eaa0e9718b79b37"}, - {file = "rpds_py-0.26.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:777c62479d12395bfb932944e61e915741e364c843afc3196b694db3d669fcd0"}, - {file = "rpds_py-0.26.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec671691e72dff75817386aa02d81e708b5a7ec0dec6669ec05213ff6b77e1bd"}, - {file = "rpds_py-0.26.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6a1cb5d6ce81379401bbb7f6dbe3d56de537fb8235979843f0d53bc2e9815a79"}, - {file = "rpds_py-0.26.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4f789e32fa1fb6a7bf890e0124e7b42d1e60d28ebff57fe806719abb75f0e9a3"}, - {file = "rpds_py-0.26.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c55b0a669976cf258afd718de3d9ad1b7d1fe0a91cd1ab36f38b03d4d4aeaaf"}, - {file = "rpds_py-0.26.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c70d9ec912802ecfd6cd390dadb34a9578b04f9bcb8e863d0a7598ba5e9e7ccc"}, - {file = "rpds_py-0.26.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3021933c2cb7def39d927b9862292e0f4c75a13d7de70eb0ab06efed4c508c19"}, - {file = "rpds_py-0.26.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8a7898b6ca3b7d6659e55cdac825a2e58c638cbf335cde41f4619e290dd0ad11"}, - {file = "rpds_py-0.26.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:12bff2ad9447188377f1b2794772f91fe68bb4bbfa5a39d7941fbebdbf8c500f"}, - {file = "rpds_py-0.26.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:191aa858f7d4902e975d4cf2f2d9243816c91e9605070aeb09c0a800d187e323"}, - {file = "rpds_py-0.26.0-cp310-cp310-win32.whl", hash = "sha256:b37a04d9f52cb76b6b78f35109b513f6519efb481d8ca4c321f6a3b9580b3f45"}, - {file = "rpds_py-0.26.0-cp310-cp310-win_amd64.whl", hash = "sha256:38721d4c9edd3eb6670437d8d5e2070063f305bfa2d5aa4278c51cedcd508a84"}, - {file = "rpds_py-0.26.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9e8cb77286025bdb21be2941d64ac6ca016130bfdcd228739e8ab137eb4406ed"}, - {file = "rpds_py-0.26.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5e09330b21d98adc8ccb2dbb9fc6cb434e8908d4c119aeaa772cb1caab5440a0"}, - {file = "rpds_py-0.26.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c9c1b92b774b2e68d11193dc39620d62fd8ab33f0a3c77ecdabe19c179cdbc1"}, - {file = "rpds_py-0.26.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:824e6d3503ab990d7090768e4dfd9e840837bae057f212ff9f4f05ec6d1975e7"}, - {file = "rpds_py-0.26.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ad7fd2258228bf288f2331f0a6148ad0186b2e3643055ed0db30990e59817a6"}, - {file = "rpds_py-0.26.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0dc23bbb3e06ec1ea72d515fb572c1fea59695aefbffb106501138762e1e915e"}, - {file = "rpds_py-0.26.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d80bf832ac7b1920ee29a426cdca335f96a2b5caa839811803e999b41ba9030d"}, - {file = "rpds_py-0.26.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0919f38f5542c0a87e7b4afcafab6fd2c15386632d249e9a087498571250abe3"}, - {file = "rpds_py-0.26.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d422b945683e409000c888e384546dbab9009bb92f7c0b456e217988cf316107"}, - {file = "rpds_py-0.26.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:77a7711fa562ba2da1aa757e11024ad6d93bad6ad7ede5afb9af144623e5f76a"}, - {file = "rpds_py-0.26.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238e8c8610cb7c29460e37184f6799547f7e09e6a9bdbdab4e8edb90986a2318"}, - {file = "rpds_py-0.26.0-cp311-cp311-win32.whl", hash = "sha256:893b022bfbdf26d7bedb083efeea624e8550ca6eb98bf7fea30211ce95b9201a"}, - {file = "rpds_py-0.26.0-cp311-cp311-win_amd64.whl", hash = "sha256:87a5531de9f71aceb8af041d72fc4cab4943648d91875ed56d2e629bef6d4c03"}, - {file = "rpds_py-0.26.0-cp311-cp311-win_arm64.whl", hash = "sha256:de2713f48c1ad57f89ac25b3cb7daed2156d8e822cf0eca9b96a6f990718cc41"}, - {file = "rpds_py-0.26.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:894514d47e012e794f1350f076c427d2347ebf82f9b958d554d12819849a369d"}, - {file = "rpds_py-0.26.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc921b96fa95a097add244da36a1d9e4f3039160d1d30f1b35837bf108c21136"}, - {file = "rpds_py-0.26.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e1157659470aa42a75448b6e943c895be8c70531c43cb78b9ba990778955582"}, - {file = "rpds_py-0.26.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:521ccf56f45bb3a791182dc6b88ae5f8fa079dd705ee42138c76deb1238e554e"}, - {file = "rpds_py-0.26.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9def736773fd56b305c0eef698be5192c77bfa30d55a0e5885f80126c4831a15"}, - {file = "rpds_py-0.26.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdad4ea3b4513b475e027be79e5a0ceac8ee1c113a1a11e5edc3c30c29f964d8"}, - {file = "rpds_py-0.26.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82b165b07f416bdccf5c84546a484cc8f15137ca38325403864bfdf2b5b72f6a"}, - {file = "rpds_py-0.26.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d04cab0a54b9dba4d278fe955a1390da3cf71f57feb78ddc7cb67cbe0bd30323"}, - {file = "rpds_py-0.26.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:79061ba1a11b6a12743a2b0f72a46aa2758613d454aa6ba4f5a265cc48850158"}, - {file = "rpds_py-0.26.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f405c93675d8d4c5ac87364bb38d06c988e11028a64b52a47158a355079661f3"}, - {file = "rpds_py-0.26.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dafd4c44b74aa4bed4b250f1aed165b8ef5de743bcca3b88fc9619b6087093d2"}, - {file = "rpds_py-0.26.0-cp312-cp312-win32.whl", hash = "sha256:3da5852aad63fa0c6f836f3359647870e21ea96cf433eb393ffa45263a170d44"}, - {file = "rpds_py-0.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:cf47cfdabc2194a669dcf7a8dbba62e37a04c5041d2125fae0233b720da6f05c"}, - {file = "rpds_py-0.26.0-cp312-cp312-win_arm64.whl", hash = "sha256:20ab1ae4fa534f73647aad289003f1104092890849e0266271351922ed5574f8"}, - {file = "rpds_py-0.26.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:696764a5be111b036256c0b18cd29783fab22154690fc698062fc1b0084b511d"}, - {file = "rpds_py-0.26.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e6c15d2080a63aaed876e228efe4f814bc7889c63b1e112ad46fdc8b368b9e1"}, - {file = "rpds_py-0.26.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:390e3170babf42462739a93321e657444f0862c6d722a291accc46f9d21ed04e"}, - {file = "rpds_py-0.26.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7da84c2c74c0f5bc97d853d9e17bb83e2dcafcff0dc48286916001cc114379a1"}, - {file = "rpds_py-0.26.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c5fe114a6dd480a510b6d3661d09d67d1622c4bf20660a474507aaee7eeeee9"}, - {file = "rpds_py-0.26.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3100b3090269f3a7ea727b06a6080d4eb7439dca4c0e91a07c5d133bb1727ea7"}, - {file = "rpds_py-0.26.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c03c9b0c64afd0320ae57de4c982801271c0c211aa2d37f3003ff5feb75bb04"}, - {file = "rpds_py-0.26.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5963b72ccd199ade6ee493723d18a3f21ba7d5b957017607f815788cef50eaf1"}, - {file = "rpds_py-0.26.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9da4e873860ad5bab3291438525cae80169daecbfafe5657f7f5fb4d6b3f96b9"}, - {file = "rpds_py-0.26.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5afaddaa8e8c7f1f7b4c5c725c0070b6eed0228f705b90a1732a48e84350f4e9"}, - {file = "rpds_py-0.26.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4916dc96489616a6f9667e7526af8fa693c0fdb4f3acb0e5d9f4400eb06a47ba"}, - {file = "rpds_py-0.26.0-cp313-cp313-win32.whl", hash = "sha256:2a343f91b17097c546b93f7999976fd6c9d5900617aa848c81d794e062ab302b"}, - {file = "rpds_py-0.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:0a0b60701f2300c81b2ac88a5fb893ccfa408e1c4a555a77f908a2596eb875a5"}, - {file = "rpds_py-0.26.0-cp313-cp313-win_arm64.whl", hash = "sha256:257d011919f133a4746958257f2c75238e3ff54255acd5e3e11f3ff41fd14256"}, - {file = "rpds_py-0.26.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:529c8156d7506fba5740e05da8795688f87119cce330c244519cf706a4a3d618"}, - {file = "rpds_py-0.26.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f53ec51f9d24e9638a40cabb95078ade8c99251945dad8d57bf4aabe86ecee35"}, - {file = "rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab504c4d654e4a29558eaa5bb8cea5fdc1703ea60a8099ffd9c758472cf913f"}, - {file = "rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd0641abca296bc1a00183fe44f7fced8807ed49d501f188faa642d0e4975b83"}, - {file = "rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:69b312fecc1d017b5327afa81d4da1480f51c68810963a7336d92203dbb3d4f1"}, - {file = "rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c741107203954f6fc34d3066d213d0a0c40f7bb5aafd698fb39888af277c70d8"}, - {file = "rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc3e55a7db08dc9a6ed5fb7103019d2c1a38a349ac41901f9f66d7f95750942f"}, - {file = "rpds_py-0.26.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9e851920caab2dbcae311fd28f4313c6953993893eb5c1bb367ec69d9a39e7ed"}, - {file = "rpds_py-0.26.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dfbf280da5f876d0b00c81f26bedce274e72a678c28845453885a9b3c22ae632"}, - {file = "rpds_py-0.26.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:1cc81d14ddfa53d7f3906694d35d54d9d3f850ef8e4e99ee68bc0d1e5fed9a9c"}, - {file = "rpds_py-0.26.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dca83c498b4650a91efcf7b88d669b170256bf8017a5db6f3e06c2bf031f57e0"}, - {file = "rpds_py-0.26.0-cp313-cp313t-win32.whl", hash = "sha256:4d11382bcaf12f80b51d790dee295c56a159633a8e81e6323b16e55d81ae37e9"}, - {file = "rpds_py-0.26.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff110acded3c22c033e637dd8896e411c7d3a11289b2edf041f86663dbc791e9"}, - {file = "rpds_py-0.26.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:da619979df60a940cd434084355c514c25cf8eb4cf9a508510682f6c851a4f7a"}, - {file = "rpds_py-0.26.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ea89a2458a1a75f87caabefe789c87539ea4e43b40f18cff526052e35bbb4fdf"}, - {file = "rpds_py-0.26.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feac1045b3327a45944e7dcbeb57530339f6b17baff154df51ef8b0da34c8c12"}, - {file = "rpds_py-0.26.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b818a592bd69bfe437ee8368603d4a2d928c34cffcdf77c2e761a759ffd17d20"}, - {file = "rpds_py-0.26.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a8b0dd8648709b62d9372fc00a57466f5fdeefed666afe3fea5a6c9539a0331"}, - {file = "rpds_py-0.26.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d3498ad0df07d81112aa6ec6c95a7e7b1ae00929fb73e7ebee0f3faaeabad2f"}, - {file = "rpds_py-0.26.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24a4146ccb15be237fdef10f331c568e1b0e505f8c8c9ed5d67759dac58ac246"}, - {file = "rpds_py-0.26.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a9a63785467b2d73635957d32a4f6e73d5e4df497a16a6392fa066b753e87387"}, - {file = "rpds_py-0.26.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:de4ed93a8c91debfd5a047be327b7cc8b0cc6afe32a716bbbc4aedca9e2a83af"}, - {file = "rpds_py-0.26.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:caf51943715b12af827696ec395bfa68f090a4c1a1d2509eb4e2cb69abbbdb33"}, - {file = "rpds_py-0.26.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4a59e5bc386de021f56337f757301b337d7ab58baa40174fb150accd480bc953"}, - {file = "rpds_py-0.26.0-cp314-cp314-win32.whl", hash = "sha256:92c8db839367ef16a662478f0a2fe13e15f2227da3c1430a782ad0f6ee009ec9"}, - {file = "rpds_py-0.26.0-cp314-cp314-win_amd64.whl", hash = "sha256:b0afb8cdd034150d4d9f53926226ed27ad15b7f465e93d7468caaf5eafae0d37"}, - {file = "rpds_py-0.26.0-cp314-cp314-win_arm64.whl", hash = "sha256:ca3f059f4ba485d90c8dc75cb5ca897e15325e4e609812ce57f896607c1c0867"}, - {file = "rpds_py-0.26.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5afea17ab3a126006dc2f293b14ffc7ef3c85336cf451564a0515ed7648033da"}, - {file = "rpds_py-0.26.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:69f0c0a3df7fd3a7eec50a00396104bb9a843ea6d45fcc31c2d5243446ffd7a7"}, - {file = "rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:801a71f70f9813e82d2513c9a96532551fce1e278ec0c64610992c49c04c2dad"}, - {file = "rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:df52098cde6d5e02fa75c1f6244f07971773adb4a26625edd5c18fee906fa84d"}, - {file = "rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9bc596b30f86dc6f0929499c9e574601679d0341a0108c25b9b358a042f51bca"}, - {file = "rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9dfbe56b299cf5875b68eb6f0ebaadc9cac520a1989cac0db0765abfb3709c19"}, - {file = "rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac64f4b2bdb4ea622175c9ab7cf09444e412e22c0e02e906978b3b488af5fde8"}, - {file = "rpds_py-0.26.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:181ef9b6bbf9845a264f9aa45c31836e9f3c1f13be565d0d010e964c661d1e2b"}, - {file = "rpds_py-0.26.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:49028aa684c144ea502a8e847d23aed5e4c2ef7cadfa7d5eaafcb40864844b7a"}, - {file = "rpds_py-0.26.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e5d524d68a474a9688336045bbf76cb0def88549c1b2ad9dbfec1fb7cfbe9170"}, - {file = "rpds_py-0.26.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1851f429b822831bd2edcbe0cfd12ee9ea77868f8d3daf267b189371671c80e"}, - {file = "rpds_py-0.26.0-cp314-cp314t-win32.whl", hash = "sha256:7bdb17009696214c3b66bb3590c6d62e14ac5935e53e929bcdbc5a495987a84f"}, - {file = "rpds_py-0.26.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f14440b9573a6f76b4ee4770c13f0b5921f71dde3b6fcb8dabbefd13b7fe05d7"}, - {file = "rpds_py-0.26.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:7a48af25d9b3c15684059d0d1fc0bc30e8eee5ca521030e2bffddcab5be40226"}, - {file = "rpds_py-0.26.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0c71c2f6bf36e61ee5c47b2b9b5d47e4d1baad6426bfed9eea3e858fc6ee8806"}, - {file = "rpds_py-0.26.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d815d48b1804ed7867b539236b6dd62997850ca1c91cad187f2ddb1b7bbef19"}, - {file = "rpds_py-0.26.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:84cfbd4d4d2cdeb2be61a057a258d26b22877266dd905809e94172dff01a42ae"}, - {file = "rpds_py-0.26.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fbaa70553ca116c77717f513e08815aec458e6b69a028d4028d403b3bc84ff37"}, - {file = "rpds_py-0.26.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39bfea47c375f379d8e87ab4bb9eb2c836e4f2069f0f65731d85e55d74666387"}, - {file = "rpds_py-0.26.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1533b7eb683fb5f38c1d68a3c78f5fdd8f1412fa6b9bf03b40f450785a0ab915"}, - {file = "rpds_py-0.26.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c5ab0ee51f560d179b057555b4f601b7df909ed31312d301b99f8b9fc6028284"}, - {file = "rpds_py-0.26.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e5162afc9e0d1f9cae3b577d9c29ddbab3505ab39012cb794d94a005825bde21"}, - {file = "rpds_py-0.26.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:43f10b007033f359bc3fa9cd5e6c1e76723f056ffa9a6b5c117cc35720a80292"}, - {file = "rpds_py-0.26.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e3730a48e5622e598293eee0762b09cff34dd3f271530f47b0894891281f051d"}, - {file = "rpds_py-0.26.0-cp39-cp39-win32.whl", hash = "sha256:4b1f66eb81eab2e0ff5775a3a312e5e2e16bf758f7b06be82fb0d04078c7ac51"}, - {file = "rpds_py-0.26.0-cp39-cp39-win_amd64.whl", hash = "sha256:519067e29f67b5c90e64fb1a6b6e9d2ec0ba28705c51956637bac23a2f4ddae1"}, - {file = "rpds_py-0.26.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3c0909c5234543ada2515c05dc08595b08d621ba919629e94427e8e03539c958"}, - {file = "rpds_py-0.26.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:c1fb0cda2abcc0ac62f64e2ea4b4e64c57dfd6b885e693095460c61bde7bb18e"}, - {file = "rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84d142d2d6cf9b31c12aa4878d82ed3b2324226270b89b676ac62ccd7df52d08"}, - {file = "rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a547e21c5610b7e9093d870be50682a6a6cf180d6da0f42c47c306073bfdbbf6"}, - {file = "rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:35e9a70a0f335371275cdcd08bc5b8051ac494dd58bff3bbfb421038220dc871"}, - {file = "rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0dfa6115c6def37905344d56fb54c03afc49104e2ca473d5dedec0f6606913b4"}, - {file = "rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:313cfcd6af1a55a286a3c9a25f64af6d0e46cf60bc5798f1db152d97a216ff6f"}, - {file = "rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f7bf2496fa563c046d05e4d232d7b7fd61346e2402052064b773e5c378bf6f73"}, - {file = "rpds_py-0.26.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:aa81873e2c8c5aa616ab8e017a481a96742fdf9313c40f14338ca7dbf50cb55f"}, - {file = "rpds_py-0.26.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:68ffcf982715f5b5b7686bdd349ff75d422e8f22551000c24b30eaa1b7f7ae84"}, - {file = "rpds_py-0.26.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6188de70e190847bb6db3dc3981cbadff87d27d6fe9b4f0e18726d55795cee9b"}, - {file = "rpds_py-0.26.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:1c962145c7473723df9722ba4c058de12eb5ebedcb4e27e7d902920aa3831ee8"}, - {file = "rpds_py-0.26.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f61a9326f80ca59214d1cceb0a09bb2ece5b2563d4e0cd37bfd5515c28510674"}, - {file = "rpds_py-0.26.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:183f857a53bcf4b1b42ef0f57ca553ab56bdd170e49d8091e96c51c3d69ca696"}, - {file = "rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:941c1cfdf4799d623cf3aa1d326a6b4fdb7a5799ee2687f3516738216d2262fb"}, - {file = "rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72a8d9564a717ee291f554eeb4bfeafe2309d5ec0aa6c475170bdab0f9ee8e88"}, - {file = "rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:511d15193cbe013619dd05414c35a7dedf2088fcee93c6bbb7c77859765bd4e8"}, - {file = "rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aea1f9741b603a8d8fedb0ed5502c2bc0accbc51f43e2ad1337fe7259c2b77a5"}, - {file = "rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4019a9d473c708cf2f16415688ef0b4639e07abaa569d72f74745bbeffafa2c7"}, - {file = "rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:093d63b4b0f52d98ebae33b8c50900d3d67e0666094b1be7a12fffd7f65de74b"}, - {file = "rpds_py-0.26.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2abe21d8ba64cded53a2a677e149ceb76dcf44284202d737178afe7ba540c1eb"}, - {file = "rpds_py-0.26.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:4feb7511c29f8442cbbc28149a92093d32e815a28aa2c50d333826ad2a20fdf0"}, - {file = "rpds_py-0.26.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:e99685fc95d386da368013e7fb4269dd39c30d99f812a8372d62f244f662709c"}, - {file = "rpds_py-0.26.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a90a13408a7a856b87be8a9f008fff53c5080eea4e4180f6c2e546e4a972fb5d"}, - {file = "rpds_py-0.26.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:3ac51b65e8dc76cf4949419c54c5528adb24fc721df722fd452e5fbc236f5c40"}, - {file = "rpds_py-0.26.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59b2093224a18c6508d95cfdeba8db9cbfd6f3494e94793b58972933fcee4c6d"}, - {file = "rpds_py-0.26.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4f01a5d6444a3258b00dc07b6ea4733e26f8072b788bef750baa37b370266137"}, - {file = "rpds_py-0.26.0-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6e2c12160c72aeda9d1283e612f68804621f448145a210f1bf1d79151c47090"}, - {file = "rpds_py-0.26.0-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cb28c1f569f8d33b2b5dcd05d0e6ef7005d8639c54c2f0be824f05aedf715255"}, - {file = "rpds_py-0.26.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1766b5724c3f779317d5321664a343c07773c8c5fd1532e4039e6cc7d1a815be"}, - {file = "rpds_py-0.26.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b6d9e5a2ed9c4988c8f9b28b3bc0e3e5b1aaa10c28d210a594ff3a8c02742daf"}, - {file = "rpds_py-0.26.0-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:b5f7a446ddaf6ca0fad9a5535b56fbfc29998bf0e0b450d174bbec0d600e1d72"}, - {file = "rpds_py-0.26.0-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:eed5ac260dd545fbc20da5f4f15e7efe36a55e0e7cf706e4ec005b491a9546a0"}, - {file = "rpds_py-0.26.0-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:582462833ba7cee52e968b0341b85e392ae53d44c0f9af6a5927c80e539a8b67"}, - {file = "rpds_py-0.26.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:69a607203441e07e9a8a529cff1d5b73f6a160f22db1097211e6212a68567d11"}, - {file = "rpds_py-0.26.0.tar.gz", hash = "sha256:20dae58a859b0906f0685642e591056f1e787f3a8b39c8e8749a45dc7d26bdb0"}, + {file = "rpds_py-0.27.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:68afeec26d42ab3b47e541b272166a0b4400313946871cba3ed3a4fc0cab1cef"}, + {file = "rpds_py-0.27.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74e5b2f7bb6fa38b1b10546d27acbacf2a022a8b5543efb06cfebc72a59c85be"}, + {file = "rpds_py-0.27.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9024de74731df54546fab0bfbcdb49fae19159ecaecfc8f37c18d2c7e2c0bd61"}, + {file = "rpds_py-0.27.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:31d3ebadefcd73b73928ed0b2fd696f7fefda8629229f81929ac9c1854d0cffb"}, + {file = "rpds_py-0.27.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2e7f8f169d775dd9092a1743768d771f1d1300453ddfe6325ae3ab5332b4657"}, + {file = "rpds_py-0.27.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d905d16f77eb6ab2e324e09bfa277b4c8e5e6b8a78a3e7ff8f3cdf773b4c013"}, + {file = "rpds_py-0.27.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50c946f048209e6362e22576baea09193809f87687a95a8db24e5fbdb307b93a"}, + {file = "rpds_py-0.27.1-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:3deab27804d65cd8289eb814c2c0e807c4b9d9916c9225e363cb0cf875eb67c1"}, + {file = "rpds_py-0.27.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8b61097f7488de4be8244c89915da8ed212832ccf1e7c7753a25a394bf9b1f10"}, + {file = "rpds_py-0.27.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8a3f29aba6e2d7d90528d3c792555a93497fe6538aa65eb675b44505be747808"}, + {file = "rpds_py-0.27.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd6cd0485b7d347304067153a6dc1d73f7d4fd995a396ef32a24d24b8ac63ac8"}, + {file = "rpds_py-0.27.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6f4461bf931108c9fa226ffb0e257c1b18dc2d44cd72b125bec50ee0ab1248a9"}, + {file = "rpds_py-0.27.1-cp310-cp310-win32.whl", hash = "sha256:ee5422d7fb21f6a00c1901bf6559c49fee13a5159d0288320737bbf6585bd3e4"}, + {file = "rpds_py-0.27.1-cp310-cp310-win_amd64.whl", hash = "sha256:3e039aabf6d5f83c745d5f9a0a381d031e9ed871967c0a5c38d201aca41f3ba1"}, + {file = "rpds_py-0.27.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:be898f271f851f68b318872ce6ebebbc62f303b654e43bf72683dbdc25b7c881"}, + {file = "rpds_py-0.27.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:62ac3d4e3e07b58ee0ddecd71d6ce3b1637de2d373501412df395a0ec5f9beb5"}, + {file = "rpds_py-0.27.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4708c5c0ceb2d034f9991623631d3d23cb16e65c83736ea020cdbe28d57c0a0e"}, + {file = "rpds_py-0.27.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:abfa1171a9952d2e0002aba2ad3780820b00cc3d9c98c6630f2e93271501f66c"}, + {file = "rpds_py-0.27.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b507d19f817ebaca79574b16eb2ae412e5c0835542c93fe9983f1e432aca195"}, + {file = "rpds_py-0.27.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:168b025f8fd8d8d10957405f3fdcef3dc20f5982d398f90851f4abc58c566c52"}, + {file = "rpds_py-0.27.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb56c6210ef77caa58e16e8c17d35c63fe3f5b60fd9ba9d424470c3400bcf9ed"}, + {file = "rpds_py-0.27.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:d252f2d8ca0195faa707f8eb9368955760880b2b42a8ee16d382bf5dd807f89a"}, + {file = "rpds_py-0.27.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6e5e54da1e74b91dbc7996b56640f79b195d5925c2b78efaa8c5d53e1d88edde"}, + {file = "rpds_py-0.27.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ffce0481cc6e95e5b3f0a47ee17ffbd234399e6d532f394c8dce320c3b089c21"}, + {file = "rpds_py-0.27.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a205fdfe55c90c2cd8e540ca9ceba65cbe6629b443bc05db1f590a3db8189ff9"}, + {file = "rpds_py-0.27.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:689fb5200a749db0415b092972e8eba85847c23885c8543a8b0f5c009b1a5948"}, + {file = "rpds_py-0.27.1-cp311-cp311-win32.whl", hash = "sha256:3182af66048c00a075010bc7f4860f33913528a4b6fc09094a6e7598e462fe39"}, + {file = "rpds_py-0.27.1-cp311-cp311-win_amd64.whl", hash = "sha256:b4938466c6b257b2f5c4ff98acd8128ec36b5059e5c8f8372d79316b1c36bb15"}, + {file = "rpds_py-0.27.1-cp311-cp311-win_arm64.whl", hash = "sha256:2f57af9b4d0793e53266ee4325535a31ba48e2f875da81a9177c9926dfa60746"}, + {file = "rpds_py-0.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ae2775c1973e3c30316892737b91f9283f9908e3cc7625b9331271eaaed7dc90"}, + {file = "rpds_py-0.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2643400120f55c8a96f7c9d858f7be0c88d383cd4653ae2cf0d0c88f668073e5"}, + {file = "rpds_py-0.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16323f674c089b0360674a4abd28d5042947d54ba620f72514d69be4ff64845e"}, + {file = "rpds_py-0.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a1f4814b65eacac94a00fc9a526e3fdafd78e439469644032032d0d63de4881"}, + {file = "rpds_py-0.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ba32c16b064267b22f1850a34051121d423b6f7338a12b9459550eb2096e7ec"}, + {file = "rpds_py-0.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5c20f33fd10485b80f65e800bbe5f6785af510b9f4056c5a3c612ebc83ba6cb"}, + {file = "rpds_py-0.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:466bfe65bd932da36ff279ddd92de56b042f2266d752719beb97b08526268ec5"}, + {file = "rpds_py-0.27.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:41e532bbdcb57c92ba3be62c42e9f096431b4cf478da9bc3bc6ce5c38ab7ba7a"}, + {file = "rpds_py-0.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f149826d742b406579466283769a8ea448eed82a789af0ed17b0cd5770433444"}, + {file = "rpds_py-0.27.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80c60cfb5310677bd67cb1e85a1e8eb52e12529545441b43e6f14d90b878775a"}, + {file = "rpds_py-0.27.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7ee6521b9baf06085f62ba9c7a3e5becffbc32480d2f1b351559c001c38ce4c1"}, + {file = "rpds_py-0.27.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a512c8263249a9d68cac08b05dd59d2b3f2061d99b322813cbcc14c3c7421998"}, + {file = "rpds_py-0.27.1-cp312-cp312-win32.whl", hash = "sha256:819064fa048ba01b6dadc5116f3ac48610435ac9a0058bbde98e569f9e785c39"}, + {file = "rpds_py-0.27.1-cp312-cp312-win_amd64.whl", hash = "sha256:d9199717881f13c32c4046a15f024971a3b78ad4ea029e8da6b86e5aa9cf4594"}, + {file = "rpds_py-0.27.1-cp312-cp312-win_arm64.whl", hash = "sha256:33aa65b97826a0e885ef6e278fbd934e98cdcfed80b63946025f01e2f5b29502"}, + {file = "rpds_py-0.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e4b9fcfbc021633863a37e92571d6f91851fa656f0180246e84cbd8b3f6b329b"}, + {file = "rpds_py-0.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1441811a96eadca93c517d08df75de45e5ffe68aa3089924f963c782c4b898cf"}, + {file = "rpds_py-0.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55266dafa22e672f5a4f65019015f90336ed31c6383bd53f5e7826d21a0e0b83"}, + {file = "rpds_py-0.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78827d7ac08627ea2c8e02c9e5b41180ea5ea1f747e9db0915e3adf36b62dcf"}, + {file = "rpds_py-0.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae92443798a40a92dc5f0b01d8a7c93adde0c4dc965310a29ae7c64d72b9fad2"}, + {file = "rpds_py-0.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c46c9dd2403b66a2a3b9720ec4b74d4ab49d4fabf9f03dfdce2d42af913fe8d0"}, + {file = "rpds_py-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2efe4eb1d01b7f5f1939f4ef30ecea6c6b3521eec451fb93191bf84b2a522418"}, + {file = "rpds_py-0.27.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:15d3b4d83582d10c601f481eca29c3f138d44c92187d197aff663a269197c02d"}, + {file = "rpds_py-0.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4ed2e16abbc982a169d30d1a420274a709949e2cbdef119fe2ec9d870b42f274"}, + {file = "rpds_py-0.27.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a75f305c9b013289121ec0f1181931975df78738cdf650093e6b86d74aa7d8dd"}, + {file = "rpds_py-0.27.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:67ce7620704745881a3d4b0ada80ab4d99df390838839921f99e63c474f82cf2"}, + {file = "rpds_py-0.27.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d992ac10eb86d9b6f369647b6a3f412fc0075cfd5d799530e84d335e440a002"}, + {file = "rpds_py-0.27.1-cp313-cp313-win32.whl", hash = "sha256:4f75e4bd8ab8db624e02c8e2fc4063021b58becdbe6df793a8111d9343aec1e3"}, + {file = "rpds_py-0.27.1-cp313-cp313-win_amd64.whl", hash = "sha256:f9025faafc62ed0b75a53e541895ca272815bec18abe2249ff6501c8f2e12b83"}, + {file = "rpds_py-0.27.1-cp313-cp313-win_arm64.whl", hash = "sha256:ed10dc32829e7d222b7d3b93136d25a406ba9788f6a7ebf6809092da1f4d279d"}, + {file = "rpds_py-0.27.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:92022bbbad0d4426e616815b16bc4127f83c9a74940e1ccf3cfe0b387aba0228"}, + {file = "rpds_py-0.27.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:47162fdab9407ec3f160805ac3e154df042e577dd53341745fc7fb3f625e6d92"}, + {file = "rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb89bec23fddc489e5d78b550a7b773557c9ab58b7946154a10a6f7a214a48b2"}, + {file = "rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e48af21883ded2b3e9eb48cb7880ad8598b31ab752ff3be6457001d78f416723"}, + {file = "rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f5b7bd8e219ed50299e58551a410b64daafb5017d54bbe822e003856f06a802"}, + {file = "rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08f1e20bccf73b08d12d804d6e1c22ca5530e71659e6673bce31a6bb71c1e73f"}, + {file = "rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dc5dceeaefcc96dc192e3a80bbe1d6c410c469e97bdd47494a7d930987f18b2"}, + {file = "rpds_py-0.27.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d76f9cc8665acdc0c9177043746775aa7babbf479b5520b78ae4002d889f5c21"}, + {file = "rpds_py-0.27.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:134fae0e36022edad8290a6661edf40c023562964efea0cc0ec7f5d392d2aaef"}, + {file = "rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb11a4f1b2b63337cfd3b4d110af778a59aae51c81d195768e353d8b52f88081"}, + {file = "rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:13e608ac9f50a0ed4faec0e90ece76ae33b34c0e8656e3dceb9a7db994c692cd"}, + {file = "rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dd2135527aa40f061350c3f8f89da2644de26cd73e4de458e79606384f4f68e7"}, + {file = "rpds_py-0.27.1-cp313-cp313t-win32.whl", hash = "sha256:3020724ade63fe320a972e2ffd93b5623227e684315adce194941167fee02688"}, + {file = "rpds_py-0.27.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8ee50c3e41739886606388ba3ab3ee2aae9f35fb23f833091833255a31740797"}, + {file = "rpds_py-0.27.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:acb9aafccaae278f449d9c713b64a9e68662e7799dbd5859e2c6b3c67b56d334"}, + {file = "rpds_py-0.27.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b7fb801aa7f845ddf601c49630deeeccde7ce10065561d92729bfe81bd21fb33"}, + {file = "rpds_py-0.27.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe0dd05afb46597b9a2e11c351e5e4283c741237e7f617ffb3252780cca9336a"}, + {file = "rpds_py-0.27.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b6dfb0e058adb12d8b1d1b25f686e94ffa65d9995a5157afe99743bf7369d62b"}, + {file = "rpds_py-0.27.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed090ccd235f6fa8bb5861684567f0a83e04f52dfc2e5c05f2e4b1309fcf85e7"}, + {file = "rpds_py-0.27.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf876e79763eecf3e7356f157540d6a093cef395b65514f17a356f62af6cc136"}, + {file = "rpds_py-0.27.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12ed005216a51b1d6e2b02a7bd31885fe317e45897de81d86dcce7d74618ffff"}, + {file = "rpds_py-0.27.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ee4308f409a40e50593c7e3bb8cbe0b4d4c66d1674a316324f0c2f5383b486f9"}, + {file = "rpds_py-0.27.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b08d152555acf1f455154d498ca855618c1378ec810646fcd7c76416ac6dc60"}, + {file = "rpds_py-0.27.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dce51c828941973a5684d458214d3a36fcd28da3e1875d659388f4f9f12cc33e"}, + {file = "rpds_py-0.27.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c1476d6f29eb81aa4151c9a31219b03f1f798dc43d8af1250a870735516a1212"}, + {file = "rpds_py-0.27.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3ce0cac322b0d69b63c9cdb895ee1b65805ec9ffad37639f291dd79467bee675"}, + {file = "rpds_py-0.27.1-cp314-cp314-win32.whl", hash = "sha256:dfbfac137d2a3d0725758cd141f878bf4329ba25e34979797c89474a89a8a3a3"}, + {file = "rpds_py-0.27.1-cp314-cp314-win_amd64.whl", hash = "sha256:a6e57b0abfe7cc513450fcf529eb486b6e4d3f8aee83e92eb5f1ef848218d456"}, + {file = "rpds_py-0.27.1-cp314-cp314-win_arm64.whl", hash = "sha256:faf8d146f3d476abfee026c4ae3bdd9ca14236ae4e4c310cbd1cf75ba33d24a3"}, + {file = "rpds_py-0.27.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ba81d2b56b6d4911ce735aad0a1d4495e808b8ee4dc58715998741a26874e7c2"}, + {file = "rpds_py-0.27.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84f7d509870098de0e864cad0102711c1e24e9b1a50ee713b65928adb22269e4"}, + {file = "rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e960fc78fecd1100539f14132425e1d5fe44ecb9239f8f27f079962021523e"}, + {file = "rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62f85b665cedab1a503747617393573995dac4600ff51869d69ad2f39eb5e817"}, + {file = "rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fed467af29776f6556250c9ed85ea5a4dd121ab56a5f8b206e3e7a4c551e48ec"}, + {file = "rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2729615f9d430af0ae6b36cf042cb55c0936408d543fb691e1a9e36648fd35a"}, + {file = "rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b207d881a9aef7ba753d69c123a35d96ca7cb808056998f6b9e8747321f03b8"}, + {file = "rpds_py-0.27.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:639fd5efec029f99b79ae47e5d7e00ad8a773da899b6309f6786ecaf22948c48"}, + {file = "rpds_py-0.27.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fecc80cb2a90e28af8a9b366edacf33d7a91cbfe4c2c4544ea1246e949cfebeb"}, + {file = "rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42a89282d711711d0a62d6f57d81aa43a1368686c45bc1c46b7f079d55692734"}, + {file = "rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:cf9931f14223de59551ab9d38ed18d92f14f055a5f78c1d8ad6493f735021bbb"}, + {file = "rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f39f58a27cc6e59f432b568ed8429c7e1641324fbe38131de852cd77b2d534b0"}, + {file = "rpds_py-0.27.1-cp314-cp314t-win32.whl", hash = "sha256:d5fa0ee122dc09e23607a28e6d7b150da16c662e66409bbe85230e4c85bb528a"}, + {file = "rpds_py-0.27.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6567d2bb951e21232c2f660c24cf3470bb96de56cdcb3f071a83feeaff8a2772"}, + {file = "rpds_py-0.27.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c918c65ec2e42c2a78d19f18c553d77319119bf43aa9e2edf7fb78d624355527"}, + {file = "rpds_py-0.27.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1fea2b1a922c47c51fd07d656324531adc787e415c8b116530a1d29c0516c62d"}, + {file = "rpds_py-0.27.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbf94c58e8e0cd6b6f38d8de67acae41b3a515c26169366ab58bdca4a6883bb8"}, + {file = "rpds_py-0.27.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c2a8fed130ce946d5c585eddc7c8eeef0051f58ac80a8ee43bd17835c144c2cc"}, + {file = "rpds_py-0.27.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:037a2361db72ee98d829bc2c5b7cc55598ae0a5e0ec1823a56ea99374cfd73c1"}, + {file = "rpds_py-0.27.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5281ed1cc1d49882f9997981c88df1a22e140ab41df19071222f7e5fc4e72125"}, + {file = "rpds_py-0.27.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fd50659a069c15eef8aa3d64bbef0d69fd27bb4a50c9ab4f17f83a16cbf8905"}, + {file = "rpds_py-0.27.1-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:c4b676c4ae3921649a15d28ed10025548e9b561ded473aa413af749503c6737e"}, + {file = "rpds_py-0.27.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:079bc583a26db831a985c5257797b2b5d3affb0386e7ff886256762f82113b5e"}, + {file = "rpds_py-0.27.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4e44099bd522cba71a2c6b97f68e19f40e7d85399de899d66cdb67b32d7cb786"}, + {file = "rpds_py-0.27.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e202e6d4188e53c6661af813b46c37ca2c45e497fc558bacc1a7630ec2695aec"}, + {file = "rpds_py-0.27.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:f41f814b8eaa48768d1bb551591f6ba45f87ac76899453e8ccd41dba1289b04b"}, + {file = "rpds_py-0.27.1-cp39-cp39-win32.whl", hash = "sha256:9e71f5a087ead99563c11fdaceee83ee982fd39cf67601f4fd66cb386336ee52"}, + {file = "rpds_py-0.27.1-cp39-cp39-win_amd64.whl", hash = "sha256:71108900c9c3c8590697244b9519017a400d9ba26a36c48381b3f64743a44aab"}, + {file = "rpds_py-0.27.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7ba22cb9693df986033b91ae1d7a979bc399237d45fccf875b76f62bb9e52ddf"}, + {file = "rpds_py-0.27.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5b640501be9288c77738b5492b3fd3abc4ba95c50c2e41273c8a1459f08298d3"}, + {file = "rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb08b65b93e0c6dd70aac7f7890a9c0938d5ec71d5cb32d45cf844fb8ae47636"}, + {file = "rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d7ff07d696a7a38152ebdb8212ca9e5baab56656749f3d6004b34ab726b550b8"}, + {file = "rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fb7c72262deae25366e3b6c0c0ba46007967aea15d1eea746e44ddba8ec58dcc"}, + {file = "rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b002cab05d6339716b03a4a3a2ce26737f6231d7b523f339fa061d53368c9d8"}, + {file = "rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23f6b69d1c26c4704fec01311963a41d7de3ee0570a84ebde4d544e5a1859ffc"}, + {file = "rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:530064db9146b247351f2a0250b8f00b289accea4596a033e94be2389977de71"}, + {file = "rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b90b0496570bd6b0321724a330d8b545827c4df2034b6ddfc5f5275f55da2ad"}, + {file = "rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:879b0e14a2da6a1102a3fc8af580fc1ead37e6d6692a781bd8c83da37429b5ab"}, + {file = "rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:0d807710df3b5faa66c731afa162ea29717ab3be17bdc15f90f2d9f183da4059"}, + {file = "rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:3adc388fc3afb6540aec081fa59e6e0d3908722771aa1e37ffe22b220a436f0b"}, + {file = "rpds_py-0.27.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c796c0c1cc68cb08b0284db4229f5af76168172670c74908fdbd4b7d7f515819"}, + {file = "rpds_py-0.27.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cdfe4bb2f9fe7458b7453ad3c33e726d6d1c7c0a72960bcc23800d77384e42df"}, + {file = "rpds_py-0.27.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8fabb8fd848a5f75a2324e4a84501ee3a5e3c78d8603f83475441866e60b94a3"}, + {file = "rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eda8719d598f2f7f3e0f885cba8646644b55a187762bec091fa14a2b819746a9"}, + {file = "rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c64d07e95606ec402a0a1c511fe003873fa6af630bda59bac77fac8b4318ebc"}, + {file = "rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93a2ed40de81bcff59aabebb626562d48332f3d028ca2036f1d23cbb52750be4"}, + {file = "rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:387ce8c44ae94e0ec50532d9cb0edce17311024c9794eb196b90e1058aadeb66"}, + {file = "rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaf94f812c95b5e60ebaf8bfb1898a7d7cb9c1af5744d4a67fa47796e0465d4e"}, + {file = "rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:4848ca84d6ded9b58e474dfdbad4b8bfb450344c0551ddc8d958bf4b36aa837c"}, + {file = "rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2bde09cbcf2248b73c7c323be49b280180ff39fadcfe04e7b6f54a678d02a7cf"}, + {file = "rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:94c44ee01fd21c9058f124d2d4f0c9dc7634bec93cd4b38eefc385dabe71acbf"}, + {file = "rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:df8b74962e35c9249425d90144e721eed198e6555a0e22a563d29fe4486b51f6"}, + {file = "rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:dc23e6820e3b40847e2f4a7726462ba0cf53089512abe9ee16318c366494c17a"}, + {file = "rpds_py-0.27.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:aa8933159edc50be265ed22b401125c9eebff3171f570258854dbce3ecd55475"}, + {file = "rpds_py-0.27.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a50431bf02583e21bf273c71b89d710e7a710ad5e39c725b14e685610555926f"}, + {file = "rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78af06ddc7fe5cc0e967085a9115accee665fb912c22a3f54bad70cc65b05fe6"}, + {file = "rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:70d0738ef8fee13c003b100c2fbd667ec4f133468109b3472d249231108283a3"}, + {file = "rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2f6fd8a1cea5bbe599b6e78a6e5ee08db434fc8ffea51ff201c8765679698b3"}, + {file = "rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8177002868d1426305bb5de1e138161c2ec9eb2d939be38291d7c431c4712df8"}, + {file = "rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:008b839781d6c9bf3b6a8984d1d8e56f0ec46dc56df61fd669c49b58ae800400"}, + {file = "rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:a55b9132bb1ade6c734ddd2759c8dc132aa63687d259e725221f106b83a0e485"}, + {file = "rpds_py-0.27.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a46fdec0083a26415f11d5f236b79fa1291c32aaa4a17684d82f7017a1f818b1"}, + {file = "rpds_py-0.27.1-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:8a63b640a7845f2bdd232eb0d0a4a2dd939bcdd6c57e6bb134526487f3160ec5"}, + {file = "rpds_py-0.27.1-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:7e32721e5d4922deaaf963469d795d5bde6093207c52fec719bd22e5d1bedbc4"}, + {file = "rpds_py-0.27.1-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:2c426b99a068601b5f4623573df7a7c3d72e87533a2dd2253353a03e7502566c"}, + {file = "rpds_py-0.27.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:4fc9b7fe29478824361ead6e14e4f5aed570d477e06088826537e202d25fe859"}, + {file = "rpds_py-0.27.1.tar.gz", hash = "sha256:26a1c73171d10b7acccbded82bf6a586ab8203601e565badc74bbbf8bc5a10f8"}, ] [[package]] @@ -1466,14 +1531,14 @@ files = [ [[package]] name = "sentry-sdk" -version = "2.27.0" +version = "2.42.1" description = "Python client for Sentry (https://sentry.io)" optional = false python-versions = ">=3.6" groups = ["main"] files = [ - {file = "sentry_sdk-2.27.0-py2.py3-none-any.whl", hash = "sha256:c58935bfff8af6a0856d37e8adebdbc7b3281c2b632ec823ef03cd108d216ff0"}, - {file = "sentry_sdk-2.27.0.tar.gz", hash = "sha256:90f4f883f9eff294aff59af3d58c2d1b64e3927b28d5ada2b9b41f5aeda47daf"}, + {file = "sentry_sdk-2.42.1-py2.py3-none-any.whl", hash = "sha256:f8716b50c927d3beb41bc88439dc6bcd872237b596df5b14613e2ade104aee02"}, + {file = "sentry_sdk-2.42.1.tar.gz", hash = "sha256:8598cc6edcfe74cb8074ba6a7c15338cdee93d63d3eb9b9943b4b568354ad5b6"}, ] [package.dependencies] @@ -1495,13 +1560,16 @@ django = ["django (>=1.8)"] falcon = ["falcon (>=1.4)"] fastapi = ["fastapi (>=0.79.0)"] flask = ["blinker (>=1.1)", "flask (>=0.11)", "markupsafe"] +google-genai = ["google-genai (>=1.29.0)"] grpcio = ["grpcio (>=1.21.1)", "protobuf (>=3.8.0)"] http2 = ["httpcore[http2] (==1.*)"] httpx = ["httpx (>=0.16.0)"] huey = ["huey (>=2)"] huggingface-hub = ["huggingface_hub (>=0.22)"] langchain = ["langchain (>=0.0.210)"] +langgraph = ["langgraph (>=0.6.6)"] launchdarkly = ["launchdarkly-server-sdk (>=9.8.0)"] +litellm = ["litellm (>=1.77.5)"] litestar = ["litestar (>=2.0.0)"] loguru = ["loguru (>=0.5)"] openai = ["openai (>=1.0.0)", "tiktoken (>=0.3.0)"] @@ -1523,15 +1591,15 @@ unleash = ["UnleashClient (>=6.0.1)"] [[package]] name = "setuptools" -version = "80.0.0" +version = "80.9.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.9" groups = ["executable"] markers = "python_version < \"3.13\"" files = [ - {file = "setuptools-80.0.0-py3-none-any.whl", hash = "sha256:a38f898dcd6e5380f4da4381a87ec90bd0a7eec23d204a5552e80ee3cab6bd27"}, - {file = "setuptools-80.0.0.tar.gz", hash = "sha256:c40a5b3729d58dd749c0f08f1a07d134fb8a0a3d7f87dc33e7c5e1f762138650"}, + {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, + {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, ] [package.extras] @@ -1585,7 +1653,8 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["main"] +markers = "python_version >= \"3.10\"" files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -1593,15 +1662,15 @@ files = [ [[package]] name = "sse-starlette" -version = "2.3.6" +version = "3.0.2" description = "SSE plugin for Starlette" optional = false python-versions = ">=3.9" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "sse_starlette-2.3.6-py3-none-any.whl", hash = "sha256:d49a8285b182f6e2228e2609c350398b2ca2c36216c2675d875f81e93548f760"}, - {file = "sse_starlette-2.3.6.tar.gz", hash = "sha256:0382336f7d4ec30160cf9ca0518962905e1b69b72d6c1c995131e0a703b436e3"}, + {file = "sse_starlette-3.0.2-py3-none-any.whl", hash = "sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a"}, + {file = "sse_starlette-3.0.2.tar.gz", hash = "sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a"}, ] [package.dependencies] @@ -1609,20 +1678,21 @@ anyio = ">=4.7.0" [package.extras] daphne = ["daphne (>=4.2.0)"] -examples = ["aiosqlite (>=0.21.0)", "fastapi (>=0.115.12)", "sqlalchemy[asyncio,examples] (>=2.0.41)", "starlette (>=0.41.3)", "uvicorn (>=0.34.0)"] +examples = ["aiosqlite (>=0.21.0)", "fastapi (>=0.115.12)", "sqlalchemy[asyncio] (>=2.0.41)", "starlette (>=0.41.3)", "uvicorn (>=0.34.0)"] granian = ["granian (>=2.3.1)"] uvicorn = ["uvicorn (>=0.34.0)"] [[package]] name = "starlette" -version = "0.47.2" +version = "0.48.0" description = "The little ASGI library that shines." optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main"] +markers = "python_version >= \"3.10\"" files = [ - {file = "starlette-0.47.2-py3-none-any.whl", hash = "sha256:c5847e96134e5c5371ee9fac6fdf1a67336d5815e09eb2a01fdb57a351ef915b"}, - {file = "starlette-0.47.2.tar.gz", hash = "sha256:6ae9aa5db235e4846decc1e7b79c4f346adf41e9777aebeb49dfd09bbd7023d8"}, + {file = "starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659"}, + {file = "starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46"}, ] [package.dependencies] @@ -1650,112 +1720,122 @@ test = ["pytest", "tornado (>=4.5)", "typeguard"] [[package]] name = "tomli" -version = "2.2.1" +version = "2.3.0" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" groups = ["test"] markers = "python_version < \"3.11\"" files = [ - {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, - {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, - {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, - {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, - {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, - {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, - {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, - {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, - {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, - {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, + {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, + {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, + {file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"}, + {file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"}, + {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"}, + {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"}, + {file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"}, + {file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"}, + {file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"}, + {file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"}, + {file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"}, + {file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"}, + {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"}, + {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"}, + {file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"}, + {file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"}, + {file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"}, + {file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"}, + {file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"}, + {file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"}, + {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"}, + {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"}, + {file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"}, + {file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"}, + {file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"}, + {file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"}, + {file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"}, + {file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"}, + {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"}, + {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"}, + {file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"}, + {file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"}, + {file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"}, + {file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"}, + {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"}, + {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"}, + {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"}, + {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"}, + {file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"}, + {file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"}, + {file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, + {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, ] [[package]] name = "typer" -version = "0.15.3" +version = "0.15.4" description = "Typer, build great CLIs. Easy to code. Based on Python type hints." optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "typer-0.15.3-py3-none-any.whl", hash = "sha256:c86a65ad77ca531f03de08d1b9cb67cd09ad02ddddf4b34745b5008f43b239bd"}, - {file = "typer-0.15.3.tar.gz", hash = "sha256:818873625d0569653438316567861899f7e9972f2e6e0c16dab608345ced713c"}, + {file = "typer-0.15.4-py3-none-any.whl", hash = "sha256:eb0651654dcdea706780c466cf06d8f174405a659ffff8f163cfbfee98c0e173"}, + {file = "typer-0.15.4.tar.gz", hash = "sha256:89507b104f9b6a0730354f27c39fae5b63ccd0c95b1ce1f1a6ba0cfd329997c3"}, ] [package.dependencies] -click = ">=8.0.0" +click = ">=8.0.0,<8.2" rich = ">=10.11.0" shellingham = ">=1.3.0" typing-extensions = ">=3.7.4.3" [[package]] name = "types-python-dateutil" -version = "2.9.0.20241206" +version = "2.9.0.20251008" description = "Typing stubs for python-dateutil" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "types_python_dateutil-2.9.0.20241206-py3-none-any.whl", hash = "sha256:e248a4bc70a486d3e3ec84d0dc30eec3a5f979d6e7ee4123ae043eedbb987f53"}, - {file = "types_python_dateutil-2.9.0.20241206.tar.gz", hash = "sha256:18f493414c26ffba692a72369fea7a154c502646301ebfe3d56a04b3767284cb"}, + {file = "types_python_dateutil-2.9.0.20251008-py3-none-any.whl", hash = "sha256:b9a5232c8921cf7661b29c163ccc56055c418ab2c6eabe8f917cbcc73a4c4157"}, + {file = "types_python_dateutil-2.9.0.20251008.tar.gz", hash = "sha256:c3826289c170c93ebd8360c3485311187df740166dbab9dd3b792e69f2bc1f9c"}, ] [[package]] name = "types-pyyaml" -version = "6.0.12.20250402" +version = "6.0.12.20250915" description = "Typing stubs for PyYAML" optional = false python-versions = ">=3.9" groups = ["test"] files = [ - {file = "types_pyyaml-6.0.12.20250402-py3-none-any.whl", hash = "sha256:652348fa9e7a203d4b0d21066dfb00760d3cbd5a15ebb7cf8d33c88a49546681"}, - {file = "types_pyyaml-6.0.12.20250402.tar.gz", hash = "sha256:d7c13c3e6d335b6af4b0122a01ff1d270aba84ab96d1a1a1063ecba3e13ec075"}, + {file = "types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6"}, + {file = "types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3"}, ] [[package]] name = "typing-extensions" -version = "4.13.2" -description = "Backported and Experimental Type Hints for Python 3.8+" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] +python-versions = ">=3.9" +groups = ["main", "test"] files = [ - {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, - {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] -markers = {dev = "python_version < \"3.13\""} +markers = {test = "python_version < \"3.11\""} [[package]] name = "typing-inspection" -version = "0.4.1" +version = "0.4.2" description = "Runtime typing introspection tools" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, - {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, ] [package.dependencies] @@ -1780,15 +1860,15 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "uvicorn" -version = "0.34.3" +version = "0.38.0" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.9" groups = ["main"] markers = "python_version >= \"3.10\" and sys_platform != \"emscripten\"" files = [ - {file = "uvicorn-0.34.3-py3-none-any.whl", hash = "sha256:16246631db62bdfbf069b0645177d6e8a77ba950cfedbfd093acef9444e4d885"}, - {file = "uvicorn-0.34.3.tar.gz", hash = "sha256:35919a9a979d7a59334b6b10e05d77c1d0d574c50e0fc98b8b1a0f165708b55a"}, + {file = "uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02"}, + {file = "uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d"}, ] [package.dependencies] @@ -1801,15 +1881,15 @@ standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3) [[package]] name = "zipp" -version = "3.21.0" +version = "3.23.0" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.9" groups = ["executable"] markers = "python_version == \"3.9\"" files = [ - {file = "zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931"}, - {file = "zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4"}, + {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, + {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, ] [package.extras] @@ -1817,10 +1897,10 @@ check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \" cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] type = ["pytest-mypy"] [metadata] lock-version = "2.1" -python-versions = ">=3.9,<3.14" -content-hash = "a32a53bea8963df5ec2c1a0db09804b8e9466e523488326c20b3f6dd21dee6d2" +python-versions = ">=3.9" +content-hash = "f0854d96f0878d9765ad704e15f5c7b53f2387a81df64a2d04e9221959720662" diff --git a/pyproject.toml b/pyproject.toml index 8fce7854..65fa2d65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,16 +1,14 @@ -[tool.poetry] +[project] name = "cycode" -version = "0.0.0" # DON'T TOUCH. Placeholder. Will be filled automatically on poetry build from Git Tag description = "Boost security in your dev lifecycle via SAST, SCA, Secrets & IaC scanning." -keywords=["secret-scan", "cycode", "devops", "token", "secret", "security", "cycode", "code"] -authors = ["Cycode "] +keywords = ["secret-scan", "cycode", "devops", "token", "secret", "security", "code"] +authors = [{name = "Cycode", email = "support@cycode.com"}] license = "MIT" -repository = "https://github.com/cycodehq/cycode-cli" +requires-python = ">=3.9" readme = "README.md" classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Console", - "License :: OSI Approved :: MIT License", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python", @@ -20,13 +18,21 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] +dynamic = ["dependencies", "version"] -[tool.poetry.scripts] +[project.scripts] cycode = "cycode.cli.app:app" +[project.urls] +repository = "https://github.com/cycodehq/cycode-cli" + +[tool.poetry] +requires-poetry = ">=2.0" +version = "0.0.0" # DON'T TOUCH. Placeholder. Will be filled automatically on poetry build from Git Tag + [tool.poetry.dependencies] -python = ">=3.9,<3.14" click = ">=8.1.0,<8.2.0" colorama = ">=0.4.3,<0.5.0" pyyaml = ">=6.0,<7.0" @@ -142,5 +148,5 @@ ban-relative-imports = "all" quote-style = "single" [build-system] -requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"] +requires = ["poetry-core>=2.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"] build-backend = "poetry_dynamic_versioning.backend" From 25a302c1bdda3f15c3884176ada1ef95e7df4c1f Mon Sep 17 00:00:00 2001 From: omerr-cycode Date: Wed, 29 Oct 2025 17:30:20 +0200 Subject: [PATCH 210/257] Document exclusion of paths from scans (#356) --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index a1ccaa7a..c483e8cb 100644 --- a/README.md +++ b/README.md @@ -917,6 +917,24 @@ git push --no-verify > [!TIP] > The pre-push hook is triggered on `git push` command and scans only the commits that are about to be pushed, making it more efficient than scanning the entire repository. +## Exclude Paths From Scans +You can use a `.cycodeignore` file to tell the Cycode CLI which files and directories to exclude from scans. +It works just like a `.gitignore` file. This helps you focus scans on your relevant code and prevent certain paths from triggering violations locally. + +### How It Works +1. Create a file named `.cycodeignore` in your workfolder. +2. List the files and directories you want to exclude, using the same patterns as `.gitignore`. +3. Place this file in the directory where you plan to run the cycode scan command. + +> [!WARNING] +> - **Invalid files**: If the `.cycodeignore` file contains a syntax error, the CLI scan will fail and return an error. +> - **Ignoring paths vs. violations**: This file is for excluding paths. It's different from the CLI's capability to ignore specific violations (for example, by using the --ignore-violation flag). + +### Supported Scanners +- SAST +- Iac (comming soon) +- SCA (comming soon) + ## Scan Results Each scan will complete with a message stating if any issues were found or not. From 8e2a160b0a56a7be7f303a15fc9165e484d7ff0b Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Wed, 29 Oct 2025 16:58:49 +0000 Subject: [PATCH 211/257] Update README.md (#357) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c483e8cb..ffc02d22 100644 --- a/README.md +++ b/README.md @@ -932,7 +932,7 @@ It works just like a `.gitignore` file. This helps you focus scans on your relev ### Supported Scanners - SAST -- Iac (comming soon) +- IaC (comming soon) - SCA (comming soon) ## Scan Results From aac1446a707aa9ed46eaee6436f653733ea7fde0 Mon Sep 17 00:00:00 2001 From: omerr-cycode Date: Thu, 20 Nov 2025 12:40:58 +0200 Subject: [PATCH 212/257] CM-54797 add SBOM import support to cli (#358) --- README.md | 25 +- cycode/cli/app.py | 3 +- cycode/cli/apps/report_import/__init__.py | 8 + .../report_import/report_import_command.py | 13 + .../cli/apps/report_import/sbom/__init__.py | 6 + .../apps/report_import/sbom/sbom_command.py | 76 ++++ cycode/cli/cli_types.py | 6 + cycode/cli/utils/get_api_client.py | 9 +- cycode/cyclient/client_creator.py | 6 + cycode/cyclient/import_sbom_client.py | 81 +++++ cycode/cyclient/models.py | 40 ++- tests/cli/commands/import/__init__.py | 0 tests/cli/commands/import/test_import_sbom.py | 329 ++++++++++++++++++ .../mocked_responses/import_sbom_client.py | 67 ++++ 14 files changed, 663 insertions(+), 6 deletions(-) create mode 100644 cycode/cli/apps/report_import/__init__.py create mode 100644 cycode/cli/apps/report_import/report_import_command.py create mode 100644 cycode/cli/apps/report_import/sbom/__init__.py create mode 100644 cycode/cli/apps/report_import/sbom/sbom_command.py create mode 100644 cycode/cyclient/import_sbom_client.py create mode 100644 tests/cli/commands/import/__init__.py create mode 100644 tests/cli/commands/import/test_import_sbom.py create mode 100644 tests/cyclient/mocked_responses/import_sbom_client.py diff --git a/README.md b/README.md index ffc02d22..0cf2dc76 100644 --- a/README.md +++ b/README.md @@ -56,8 +56,9 @@ This guide walks you through both installation and usage. 6. [Ignoring via a config file](#ignoring-via-a-config-file) 6. [Report command](#report-command) 1. [Generating SBOM Report](#generating-sbom-report) -7. [Scan logs](#scan-logs) -8. [Syntax Help](#syntax-help) +7. [Import command](#import-command) +8. [Scan logs](#scan-logs) +9. [Syntax Help](#syntax-help) # Prerequisites @@ -1295,6 +1296,26 @@ To create an SBOM report for a path:\ For example:\ `cycode report sbom --format spdx-2.3 --include-vulnerabilities --include-dev-dependencies path /path/to/local/project` +# Import Command + +## Importing SBOM + +A software bill of materials (SBOM) is an inventory of all constituent components and software dependencies involved in the development and delivery of an application. +Using this command, you can import an SBOM file from your file system into Cycode. + +The following options are available for use with this command: + +| Option | Description | Required | Default | +|----------------------------------------------------|--------------------------------------------|----------|-------------------------------------------------------| +| `-n, --name TEXT` | Display name of the SBOM | Yes | | +| `-v, --vendor TEXT` | Name of the entity that provided the SBOM | Yes | | +| `-l, --label TEXT` | Attach label to the SBOM | No | | +| `-o, --owner TEXT` | Email address of the Cycode user that serves as point of contact for this SBOM | No | | +| `-b, --business-impact [High \| Medium \| Low]` | Business Impact | No | Medium | + +For example:\ +`cycode import sbom --name example-sbom --vendor cycode -label tag1 -label tag2 --owner example@cycode.com /path/to/local/project` + # Scan Logs All CLI scans are logged in Cycode. The logs can be found under Settings > CLI Logs. diff --git a/cycode/cli/app.py b/cycode/cli/app.py index 1b13ebf2..04872b7d 100644 --- a/cycode/cli/app.py +++ b/cycode/cli/app.py @@ -9,7 +9,7 @@ from typer.completion import install_callback, show_callback from cycode import __version__ -from cycode.cli.apps import ai_remediation, auth, configure, ignore, report, scan, status +from cycode.cli.apps import ai_remediation, auth, configure, ignore, report, report_import, scan, status if sys.version_info >= (3, 10): from cycode.cli.apps import mcp @@ -50,6 +50,7 @@ app.add_typer(configure.app) app.add_typer(ignore.app) app.add_typer(report.app) +app.add_typer(report_import.app) app.add_typer(scan.app) app.add_typer(status.app) if sys.version_info >= (3, 10): diff --git a/cycode/cli/apps/report_import/__init__.py b/cycode/cli/apps/report_import/__init__.py new file mode 100644 index 00000000..1fd2475b --- /dev/null +++ b/cycode/cli/apps/report_import/__init__.py @@ -0,0 +1,8 @@ +import typer + +from cycode.cli.apps.report_import.report_import_command import report_import_command +from cycode.cli.apps.report_import.sbom import sbom_command + +app = typer.Typer(name='import', no_args_is_help=True) +app.callback(short_help='Import report. You`ll need to specify which report type to import.')(report_import_command) +app.command(name='sbom', short_help='Import SBOM report from a local path.')(sbom_command) diff --git a/cycode/cli/apps/report_import/report_import_command.py b/cycode/cli/apps/report_import/report_import_command.py new file mode 100644 index 00000000..7f4e8844 --- /dev/null +++ b/cycode/cli/apps/report_import/report_import_command.py @@ -0,0 +1,13 @@ +import typer + +from cycode.cli.utils.sentry import add_breadcrumb + + +def report_import_command(ctx: typer.Context) -> int: + """:bar_chart: [bold cyan]Import security reports.[/] + + Example usage: + * `cycode import sbom`: Import SBOM report + """ + add_breadcrumb('import') + return 1 diff --git a/cycode/cli/apps/report_import/sbom/__init__.py b/cycode/cli/apps/report_import/sbom/__init__.py new file mode 100644 index 00000000..4d667031 --- /dev/null +++ b/cycode/cli/apps/report_import/sbom/__init__.py @@ -0,0 +1,6 @@ +import typer + +from cycode.cli.apps.report_import.sbom.sbom_command import sbom_command + +app = typer.Typer(name='sbom') +app.command(name='path', short_help='Import SBOM report from a local path.')(sbom_command) diff --git a/cycode/cli/apps/report_import/sbom/sbom_command.py b/cycode/cli/apps/report_import/sbom/sbom_command.py new file mode 100644 index 00000000..de9e85d4 --- /dev/null +++ b/cycode/cli/apps/report_import/sbom/sbom_command.py @@ -0,0 +1,76 @@ +from pathlib import Path +from typing import Annotated, Optional + +import typer + +from cycode.cli.cli_types import BusinessImpactOption +from cycode.cli.exceptions.handle_report_sbom_errors import handle_report_exception +from cycode.cli.utils.get_api_client import get_import_sbom_cycode_client +from cycode.cli.utils.sentry import add_breadcrumb +from cycode.cyclient.import_sbom_client import ImportSbomParameters + + +def sbom_command( + ctx: typer.Context, + path: Annotated[ + Path, + typer.Argument( + exists=True, resolve_path=True, dir_okay=False, readable=True, help='Path to SBOM file.', show_default=False + ), + ], + sbom_name: Annotated[ + str, typer.Option('--name', '-n', help='SBOM Name.', case_sensitive=False, show_default=False) + ], + vendor: Annotated[ + str, typer.Option('--vendor', '-v', help='Vendor Name.', case_sensitive=False, show_default=False) + ], + labels: Annotated[ + Optional[list[str]], + typer.Option( + '--label', '-l', help='Label, can be specified multiple times.', case_sensitive=False, show_default=False + ), + ] = None, + owners: Annotated[ + Optional[list[str]], + typer.Option( + '--owner', + '-o', + help='Email address of a user in Cycode platform, can be specified multiple times.', + case_sensitive=True, + show_default=False, + ), + ] = None, + business_impact: Annotated[ + BusinessImpactOption, + typer.Option( + '--business-impact', + '-b', + help='Business Impact.', + case_sensitive=True, + show_default=True, + ), + ] = BusinessImpactOption.MEDIUM, +) -> None: + """Import SBOM.""" + add_breadcrumb('sbom') + + client = get_import_sbom_cycode_client(ctx) + + import_parameters = ImportSbomParameters( + Name=sbom_name, + Vendor=vendor, + BusinessImpact=business_impact, + Labels=labels, + Owners=owners, + ) + + try: + if not path.exists(): + from errno import ENOENT + from os import strerror + + raise FileNotFoundError(ENOENT, strerror(ENOENT), path.absolute()) + + client.request_sbom_import_execution(import_parameters, path) + except Exception as e: + handle_report_exception(ctx, e) diff --git a/cycode/cli/cli_types.py b/cycode/cli/cli_types.py index a5d7f9d9..63a1cb36 100644 --- a/cycode/cli/cli_types.py +++ b/cycode/cli/cli_types.py @@ -52,6 +52,12 @@ class SbomOutputFormatOption(StrEnum): JSON = 'json' +class BusinessImpactOption(StrEnum): + HIGH = 'High' + MEDIUM = 'Medium' + LOW = 'Low' + + class SeverityOption(StrEnum): INFO = 'info' LOW = 'low' diff --git a/cycode/cli/utils/get_api_client.py b/cycode/cli/utils/get_api_client.py index 110d528b..ba98d937 100644 --- a/cycode/cli/utils/get_api_client.py +++ b/cycode/cli/utils/get_api_client.py @@ -3,11 +3,12 @@ import click from cycode.cli.user_settings.credentials_manager import CredentialsManager -from cycode.cyclient.client_creator import create_report_client, create_scan_client +from cycode.cyclient.client_creator import create_import_sbom_client, create_report_client, create_scan_client if TYPE_CHECKING: import typer + from cycode.cyclient.import_sbom_client import ImportSbomClient from cycode.cyclient.report_client import ReportClient from cycode.cyclient.scan_client import ScanClient @@ -38,6 +39,12 @@ def get_report_cycode_client(ctx: 'typer.Context', hide_response_log: bool = Tru return _get_cycode_client(create_report_client, client_id, client_secret, hide_response_log) +def get_import_sbom_cycode_client(ctx: 'typer.Context', hide_response_log: bool = True) -> 'ImportSbomClient': + client_id = ctx.obj.get('client_id') + client_secret = ctx.obj.get('client_secret') + return _get_cycode_client(create_import_sbom_client, client_id, client_secret, hide_response_log) + + def _get_configured_credentials() -> tuple[str, str]: credentials_manager = CredentialsManager() return credentials_manager.get_credentials() diff --git a/cycode/cyclient/client_creator.py b/cycode/cyclient/client_creator.py index 45911589..68845646 100644 --- a/cycode/cyclient/client_creator.py +++ b/cycode/cyclient/client_creator.py @@ -2,6 +2,7 @@ from cycode.cyclient.config_dev import DEV_CYCODE_API_URL from cycode.cyclient.cycode_dev_based_client import CycodeDevBasedClient from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient +from cycode.cyclient.import_sbom_client import ImportSbomClient from cycode.cyclient.report_client import ReportClient from cycode.cyclient.scan_client import ScanClient from cycode.cyclient.scan_config_base import DefaultScanConfig, DevScanConfig @@ -21,3 +22,8 @@ def create_scan_client(client_id: str, client_secret: str, hide_response_log: bo def create_report_client(client_id: str, client_secret: str, _: bool) -> ReportClient: client = CycodeDevBasedClient(DEV_CYCODE_API_URL) if dev_mode else CycodeTokenBasedClient(client_id, client_secret) return ReportClient(client) + + +def create_import_sbom_client(client_id: str, client_secret: str, _: bool) -> ImportSbomClient: + client = CycodeDevBasedClient(DEV_CYCODE_API_URL) if dev_mode else CycodeTokenBasedClient(client_id, client_secret) + return ImportSbomClient(client) diff --git a/cycode/cyclient/import_sbom_client.py b/cycode/cyclient/import_sbom_client.py new file mode 100644 index 00000000..d8107cf5 --- /dev/null +++ b/cycode/cyclient/import_sbom_client.py @@ -0,0 +1,81 @@ +import dataclasses +from pathlib import Path +from typing import Optional + +from requests import Response + +from cycode.cli.cli_types import BusinessImpactOption +from cycode.cli.exceptions.custom_exceptions import RequestHttpError +from cycode.cyclient import models +from cycode.cyclient.cycode_client_base import CycodeClientBase + + +@dataclasses.dataclass +class ImportSbomParameters: + Name: str + Vendor: str + BusinessImpact: BusinessImpactOption + Labels: Optional[list[str]] + Owners: Optional[list[str]] + + def _owners_to_ids(self) -> list[str]: + return [] + + def to_request_form(self) -> dict: + form_data = {} + for field in dataclasses.fields(self): + key = field.name + val = getattr(self, key) + if val is None or len(val) == 0: + continue + if isinstance(val, list): + form_data[f'{key}[]'] = val + else: + form_data[key] = val + return form_data + + +class ImportSbomClient: + IMPORT_SBOM_REQUEST_PATH: str = 'v4/sbom/import' + GET_USER_ID_REQUEST_PATH: str = 'v4/members' + + def __init__(self, client: CycodeClientBase) -> None: + self.client = client + + def request_sbom_import_execution(self, params: ImportSbomParameters, file_path: Path) -> None: + if params.Owners: + owners_ids = self.get_owners_user_ids(params.Owners) + params.Owners = owners_ids + + form_data = params.to_request_form() + + with open(file_path.absolute(), 'rb') as f: + request_args = { + 'url_path': self.IMPORT_SBOM_REQUEST_PATH, + 'data': form_data, + 'files': {'File': f}, + } + + response = self.client.post(**request_args) + + if response.status_code != 201: + raise RequestHttpError(response.status_code, response.text, response) + + def get_owners_user_ids(self, owners: list[str]) -> list[str]: + return [self._get_user_id_by_email(owner) for owner in owners] + + def _get_user_id_by_email(self, email: str) -> str: + request_args = {'url_path': self.GET_USER_ID_REQUEST_PATH, 'params': {'email': email}} + + response = self.client.get(**request_args) + member_details = self.parse_requested_member_details_response(response) + + if not member_details.items: + raise Exception( + f"Failed to find user with email '{email}'. Verify this email is registered to Cycode platform" + ) + return member_details.items.pop(0).external_id + + @staticmethod + def parse_requested_member_details_response(response: Response) -> models.MemberDetails: + return models.RequestedMemberDetailsResultSchema().load(response.json()) diff --git a/cycode/cyclient/models.py b/cycode/cyclient/models.py index f6419645..c3144a53 100644 --- a/cycode/cyclient/models.py +++ b/cycode/cyclient/models.py @@ -47,10 +47,10 @@ class DetectionSchema(Schema): class Meta: unknown = EXCLUDE - id = fields.String(missing=None) + id = fields.String(load_default=None) message = fields.String() type = fields.String() - severity = fields.String(missing=None) + severity = fields.String(load_default=None) detection_type_id = fields.String() detection_details = fields.Dict() detection_rule_id = fields.String() @@ -402,6 +402,42 @@ def build_dto(self, data: dict[str, Any], **_) -> SbomReport: return SbomReport(**data) +@dataclass +class Member: + external_id: str + + +class MemberSchema(Schema): + class Meta: + unknown = EXCLUDE + + external_id = fields.String() + + @post_load + def build_dto(self, data: dict[str, Any], **_) -> Member: + return Member(**data) + + +@dataclass +class MemberDetails: + items: list[Member] + page_size: int + next_page_token: Optional[str] + + +class RequestedMemberDetailsResultSchema(Schema): + class Meta: + unknown = EXCLUDE + + items = fields.List(fields.Nested(MemberSchema)) + page_size = fields.Integer() + next_page_token = fields.String(allow_none=True) + + @post_load + def build_dto(self, data: dict[str, Any], **_) -> MemberDetails: + return MemberDetails(**data) + + @dataclass class ClassificationData: severity: str diff --git a/tests/cli/commands/import/__init__.py b/tests/cli/commands/import/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/commands/import/test_import_sbom.py b/tests/cli/commands/import/test_import_sbom.py new file mode 100644 index 00000000..ecea2c6d --- /dev/null +++ b/tests/cli/commands/import/test_import_sbom.py @@ -0,0 +1,329 @@ +import tempfile +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest +import responses +from typer.testing import CliRunner + +from cycode.cli.app import app +from cycode.cli.cli_types import BusinessImpactOption +from cycode.cyclient.client_creator import create_import_sbom_client +from cycode.cyclient.import_sbom_client import ImportSbomClient, ImportSbomParameters +from tests.conftest import _CLIENT_ID, _CLIENT_SECRET, CLI_ENV_VARS +from tests.cyclient.mocked_responses import import_sbom_client as mocked_import_sbom + +if TYPE_CHECKING: + from pytest_mock import MockerFixture + + +@pytest.fixture(scope='session') +def import_sbom_client() -> ImportSbomClient: + return create_import_sbom_client(_CLIENT_ID, _CLIENT_SECRET, False) + + +def _validate_called_endpoint(calls: responses.CallList, path: str, expected_count: int = 1) -> None: + # Verify the import request was made + import_calls = [c for c in calls if path in c.request.url] + assert len(import_calls) == expected_count + + +class TestImportSbomParameters: + """Tests for ImportSbomParameters.to_request_form() method""" + + def test_to_request_form_with_all_fields(self) -> None: + data = { + 'Name': 'test-sbom', + 'Vendor': 'test-vendor', + 'BusinessImpact': BusinessImpactOption.HIGH, + 'Labels': ['label1', 'label2'], + 'Owners': ['owner1-id', 'owner2-id'], + } + + params = ImportSbomParameters(**data) + form_data = params.to_request_form() + + for key, val in data.items(): + if isinstance(val, list): + assert form_data[f'{key}[]'] == val + else: + assert form_data[key] == val + + def test_to_request_form_with_required_fields_only(self) -> None: + params = ImportSbomParameters( + Name='test-sbom', + Vendor='test-vendor', + BusinessImpact=BusinessImpactOption.MEDIUM, + Labels=[], + Owners=[], + ) + form_data = params.to_request_form() + + # Assert + assert form_data['Name'] == 'test-sbom' + assert form_data['Vendor'] == 'test-vendor' + assert form_data['BusinessImpact'] == BusinessImpactOption.MEDIUM + assert 'Labels[]' not in form_data + assert 'Owners[]' not in form_data + + +class TestSbomCommand: + """Tests for sbom_command with CLI integration""" + + @responses.activate + def test_sbom_command_happy_path( + self, + mocker: 'MockerFixture', + api_token_response: responses.Response, + import_sbom_client: ImportSbomClient, + ) -> None: + responses.add(api_token_response) + mocked_import_sbom.mock_import_sbom_responses(responses, import_sbom_client) + + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as temp_file: + temp_file.write('{"sbom": "content"}') + temp_file_path = temp_file.name + + try: + args = [ + 'import', + 'sbom', + '--name', + 'test-sbom', + '--vendor', + 'test-vendor', + temp_file_path, + ] + result = CliRunner().invoke(app, args, env=CLI_ENV_VARS) + + assert result.exit_code == 0 + _validate_called_endpoint(responses.calls, ImportSbomClient.IMPORT_SBOM_REQUEST_PATH) + + finally: + Path(temp_file_path).unlink(missing_ok=True) + + @responses.activate + def test_sbom_command_with_all_options( + self, + mocker: 'MockerFixture', + api_token_response: responses.Response, + import_sbom_client: ImportSbomClient, + ) -> None: + responses.add(api_token_response) + mocked_import_sbom.mock_member_details_response(responses, import_sbom_client, 'user1@example.com', 'user-123') + mocked_import_sbom.mock_import_sbom_responses(responses, import_sbom_client) + + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as temp_file: + temp_file.write('{"sbom": "content"}') + temp_file_path = temp_file.name + + try: + args = [ + 'import', + 'sbom', + '--name', + 'test-sbom', + '--vendor', + 'test-vendor', + '--label', + 'production', + '--label', + 'critical', + '--owner', + 'user1@example.com', + '--business-impact', + 'High', + temp_file_path, + ] + result = CliRunner().invoke(app, args, env=CLI_ENV_VARS) + + # Assert + assert result.exit_code == 0 + _validate_called_endpoint(responses.calls, ImportSbomClient.IMPORT_SBOM_REQUEST_PATH) + _validate_called_endpoint(responses.calls, ImportSbomClient.GET_USER_ID_REQUEST_PATH) + + finally: + Path(temp_file_path).unlink(missing_ok=True) + + def test_sbom_command_file_not_exists(self, mocker: 'MockerFixture') -> None: + from uuid import uuid4 + + non_existent_file = str(uuid4()) + + args = [ + 'import', + 'sbom', + '--name', + 'test-sbom', + '--vendor', + 'test-vendor', + non_existent_file, + ] + result = CliRunner().invoke(app, args, env=CLI_ENV_VARS) + + assert result.exit_code != 0 + assert "Invalid value for 'PATH': File " in result.output + assert 'not exist' in result.output + + def test_sbom_command_file_is_directory(self, mocker: 'MockerFixture') -> None: + with tempfile.TemporaryDirectory() as temp_dir: + args = [ + 'import', + 'sbom', + '--name', + 'test-sbom', + '--vendor', + 'test-vendor', + temp_dir, + ] + result = CliRunner().invoke(app, args, env=CLI_ENV_VARS) + + # Typer should reject this before the command runs + assert result.exit_code != 0 + # The error message contains "is a" and "directory" (may be across lines) + assert 'directory' in result.output.lower() + + @responses.activate + def test_sbom_command_invalid_owner_email( + self, + mocker: 'MockerFixture', + api_token_response: responses.Response, + import_sbom_client: ImportSbomClient, + ) -> None: + responses.add(api_token_response) + # Mock with no external_id to simulate user not found + mocked_import_sbom.mock_member_details_response(responses, import_sbom_client, 'invalid@example.com', None) + + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as temp_file: + temp_file.write('{"sbom": "content"}') + temp_file_path = temp_file.name + + try: + args = [ + 'import', + 'sbom', + '--name', + 'test-sbom', + '--vendor', + 'test-vendor', + '--owner', + 'invalid@example.com', + temp_file_path, + ] + result = CliRunner().invoke(app, args, env=CLI_ENV_VARS) + + assert result.exit_code == 1 + assert 'invalid@example.com' in result.output + assert 'Failed to find user' in result.output or 'not found' in result.output.lower() + finally: + Path(temp_file_path).unlink(missing_ok=True) + + @responses.activate + def test_sbom_command_http_400_error( + self, + mocker: 'MockerFixture', + api_token_response: responses.Response, + import_sbom_client: ImportSbomClient, + ) -> None: + responses.add(api_token_response) + # Mock the SBOM import API endpoint to return 400 + mocked_import_sbom.mock_import_sbom_responses(responses, import_sbom_client, status=400) + + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as temp_file: + temp_file.write('{"sbom": "content"}') + temp_file_path = temp_file.name + + try: + args = [ + 'import', + 'sbom', + '--name', + 'test-sbom', + '--vendor', + 'test-vendor', + temp_file_path, + ] + result = CliRunner().invoke(app, args, env=CLI_ENV_VARS) + + # HTTP 400 errors are also soft failures - exit with code 0 + assert result.exit_code == 0 + _validate_called_endpoint(responses.calls, ImportSbomClient.IMPORT_SBOM_REQUEST_PATH) + finally: + Path(temp_file_path).unlink(missing_ok=True) + + @responses.activate + def test_sbom_command_http_500_error( + self, + mocker: 'MockerFixture', + api_token_response: responses.Response, + import_sbom_client: ImportSbomClient, + ) -> None: + responses.add(api_token_response) + # Mock the SBOM import API endpoint to return 500 (soft failure) + mocked_import_sbom.mock_import_sbom_responses(responses, import_sbom_client, status=500) + + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as temp_file: + temp_file.write('{"sbom": "content"}') + temp_file_path = temp_file.name + + try: + args = [ + 'import', + 'sbom', + '--name', + 'test-sbom', + '--vendor', + 'test-vendor', + temp_file_path, + ] + result = CliRunner().invoke(app, args, env=CLI_ENV_VARS) + + assert result.exit_code == 0 + _validate_called_endpoint(responses.calls, ImportSbomClient.IMPORT_SBOM_REQUEST_PATH) + finally: + Path(temp_file_path).unlink(missing_ok=True) + + @responses.activate + def test_sbom_command_multiple_owners( + self, + mocker: 'MockerFixture', + api_token_response: responses.Response, + import_sbom_client: ImportSbomClient, + ) -> None: + username1 = 'user1' + username2 = 'user2' + + responses.add(api_token_response) + mocked_import_sbom.mock_import_sbom_responses(responses, import_sbom_client) + mocked_import_sbom.mock_member_details_response( + responses, import_sbom_client, f'{username1}@example.com', 'user-123' + ) + mocked_import_sbom.mock_member_details_response( + responses, import_sbom_client, f'{username2}@example.com', 'user-456' + ) + + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as temp_file: + temp_file.write('{"sbom": "content"}') + temp_file_path = temp_file.name + + try: + args = [ + 'import', + 'sbom', + '--name', + 'test-sbom', + '--vendor', + 'test-vendor', + '--owner', + f'{username1}@example.com', + '--owner', + f'{username2}@example.com', + temp_file_path, + ] + result = CliRunner().invoke(app, args, env=CLI_ENV_VARS) + + assert result.exit_code == 0 + _validate_called_endpoint(responses.calls, ImportSbomClient.IMPORT_SBOM_REQUEST_PATH) + _validate_called_endpoint(responses.calls, ImportSbomClient.GET_USER_ID_REQUEST_PATH, 2) + finally: + Path(temp_file_path).unlink(missing_ok=True) diff --git a/tests/cyclient/mocked_responses/import_sbom_client.py b/tests/cyclient/mocked_responses/import_sbom_client.py new file mode 100644 index 00000000..04398ace --- /dev/null +++ b/tests/cyclient/mocked_responses/import_sbom_client.py @@ -0,0 +1,67 @@ +from typing import Optional + +import responses +from responses import matchers + +from cycode.cyclient.import_sbom_client import ImportSbomClient + + +def get_import_sbom_url(import_sbom_client: ImportSbomClient) -> str: + api_url = import_sbom_client.client.api_url + service_url = ImportSbomClient.IMPORT_SBOM_REQUEST_PATH + return f'{api_url}/{service_url}' + + +def get_import_sbom_response(url: str, status: int = 201) -> responses.Response: + json_response = {'message': 'SBOM imported successfully'} + return responses.Response(method=responses.POST, url=url, json=json_response, status=status) + + +def get_member_details_url(import_sbom_client: ImportSbomClient) -> str: + api_url = import_sbom_client.client.api_url + service_url = ImportSbomClient.GET_USER_ID_REQUEST_PATH + return f'{api_url}/{service_url}' + + +def get_member_details_response( + url: str, email: str, external_id: Optional[str] = None, status: int = 200 +) -> responses.Response: + items = [] + if external_id: + items = [{'external_id': external_id, 'email': email}] + + json_response = { + 'items': items, + 'page_size': 10, + 'next_page_token': None, + } + + return responses.Response( + method=responses.GET, + url=url, + json=json_response, + status=status, + match=[matchers.query_param_matcher({'email': email})], + ) + + +def mock_import_sbom_responses( + responses_module: responses, + import_sbom_client: ImportSbomClient, + status: int = 201, +) -> None: + """Mock the basic SBOM import endpoint""" + responses_module.add(get_import_sbom_response(get_import_sbom_url(import_sbom_client), status)) + + +def mock_member_details_response( + responses_module: responses, + import_sbom_client: ImportSbomClient, + email: str, + external_id: Optional[str] = None, + status: int = 200, +) -> None: + """Mock the member details lookup endpoint""" + responses_module.add( + get_member_details_response(get_member_details_url(import_sbom_client), email, external_id, status) + ) From c223e044d117da1686b1d5495bc1c475285ef19f Mon Sep 17 00:00:00 2001 From: morsa4406 <104304449+morsa4406@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:24:22 +0200 Subject: [PATCH 213/257] CM-55551 CLI SCA Scan Fails to Detect Indirect Dependencies Due to PNPM Lock File Handling (#360) --- .../sca/base_restore_dependencies.py | 24 +++++++++++++++++-- .../sca/go/restore_go_dependencies.py | 3 +++ .../sca/maven/restore_gradle_dependencies.py | 3 +++ .../sca/maven/restore_maven_dependencies.py | 3 +++ .../sca/npm/restore_npm_dependencies.py | 7 ++++++ .../sca/nuget/restore_nuget_dependencies.py | 3 +++ .../sca/ruby/restore_ruby_dependencies.py | 3 +++ .../sca/sbt/restore_sbt_dependencies.py | 3 +++ 8 files changed, 47 insertions(+), 2 deletions(-) diff --git a/cycode/cli/files_collector/sca/base_restore_dependencies.py b/cycode/cli/files_collector/sca/base_restore_dependencies.py index de409f05..80ef4183 100644 --- a/cycode/cli/files_collector/sca/base_restore_dependencies.py +++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py @@ -57,8 +57,14 @@ def get_manifest_file_path(self, document: Document) -> str: def try_restore_dependencies(self, document: Document) -> Optional[Document]: manifest_file_path = self.get_manifest_file_path(document) - restore_file_path = build_dep_tree_path(document.absolute_path, self.get_lock_file_name()) - relative_restore_file_path = build_dep_tree_path(document.path, self.get_lock_file_name()) + restore_file_paths = [ + build_dep_tree_path(document.absolute_path, restore_file_path_item) + for restore_file_path_item in self.get_lock_file_names() + ] + restore_file_path = self.get_any_restore_file_already_exist(document, restore_file_paths) + relative_restore_file_path = build_dep_tree_path( + document.path, self.get_restored_lock_file_name(restore_file_path) + ) if not self.verify_restore_file_already_exist(restore_file_path): output = execute_commands( @@ -76,6 +82,16 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: def get_working_directory(self, document: Document) -> Optional[str]: return os.path.dirname(document.absolute_path) + def get_restored_lock_file_name(self, restore_file_path: str) -> str: + return self.get_lock_file_name() + + def get_any_restore_file_already_exist(self, document: Document, restore_file_paths: list[str]) -> str: + for restore_file_path in restore_file_paths: + if os.path.isfile(restore_file_path): + return restore_file_path + + return build_dep_tree_path(document.absolute_path, self.get_lock_file_name()) + @staticmethod def verify_restore_file_already_exist(restore_file_path: str) -> bool: return os.path.isfile(restore_file_path) @@ -91,3 +107,7 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: @abstractmethod def get_lock_file_name(self) -> str: pass + + @abstractmethod + def get_lock_file_names(self) -> list[str]: + pass diff --git a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py index 156b0cc0..b57812b9 100644 --- a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py +++ b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py @@ -43,3 +43,6 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: def get_lock_file_name(self) -> str: return GO_RESTORE_FILE_NAME + + def get_lock_file_names(self) -> str: + return [self.get_lock_file_name()] diff --git a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py index 75e1e8f7..4e4f36eb 100644 --- a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py @@ -41,6 +41,9 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: def get_lock_file_name(self) -> str: return BUILD_GRADLE_DEP_TREE_FILE_NAME + def get_lock_file_names(self) -> str: + return [self.get_lock_file_name()] + def get_working_directory(self, document: Document) -> Optional[str]: return get_path_from_context(self.ctx) if self.is_gradle_sub_projects() else None diff --git a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py index 51c91aa9..50e55f10 100644 --- a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py @@ -34,6 +34,9 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: def get_lock_file_name(self) -> str: return join_paths('target', MAVEN_CYCLONE_DEP_TREE_FILE_NAME) + def get_lock_file_names(self) -> str: + return [self.get_lock_file_name()] + def try_restore_dependencies(self, document: Document) -> Optional[Document]: manifest_file_path = self.get_manifest_file_path(document) if document.content is None: diff --git a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py index 2563612f..120d7de7 100644 --- a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py +++ b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py @@ -7,6 +7,7 @@ NPM_PROJECT_FILE_EXTENSIONS = ['.json'] NPM_LOCK_FILE_NAME = 'package-lock.json' +NPM_LOCK_FILE_NAMES = [NPM_LOCK_FILE_NAME, 'yarn.lock', 'pnpm-lock.yaml', 'deno.lock'] NPM_MANIFEST_FILE_NAME = 'package.json' @@ -30,9 +31,15 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: ] ] + def get_restored_lock_file_name(self, restore_file_path: str) -> str: + return os.path.basename(restore_file_path) + def get_lock_file_name(self) -> str: return NPM_LOCK_FILE_NAME + def get_lock_file_names(self) -> str: + return NPM_LOCK_FILE_NAMES + @staticmethod def prepare_manifest_file_path_for_command(manifest_file_path: str) -> str: return manifest_file_path.replace(os.sep + NPM_MANIFEST_FILE_NAME, '') diff --git a/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py b/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py index 3035e206..1e439bbd 100644 --- a/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py +++ b/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py @@ -19,3 +19,6 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: def get_lock_file_name(self) -> str: return NUGET_LOCK_FILE_NAME + + def get_lock_file_names(self) -> str: + return [self.get_lock_file_name()] diff --git a/cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py b/cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py index fb4a7771..5e0fbe75 100644 --- a/cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py +++ b/cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py @@ -14,3 +14,6 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: def get_lock_file_name(self) -> str: return RUBY_LOCK_FILE_NAME + + def get_lock_file_names(self) -> str: + return [self.get_lock_file_name()] diff --git a/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py b/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py index 4f4bbd5a..bb2a9626 100644 --- a/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py +++ b/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py @@ -14,3 +14,6 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: def get_lock_file_name(self) -> str: return SBT_LOCK_FILE_NAME + + def get_lock_file_names(self) -> str: + return [self.get_lock_file_name()] From 251874e59de834251075893baf70eaede9b4eb80 Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Tue, 25 Nov 2025 13:24:37 +0000 Subject: [PATCH 214/257] CM-55207: cover some edge cases in commit pre-receive (#359) --- .../scan/pre_receive/pre_receive_command.py | 4 +- .../files_collector/commit_range_documents.py | 34 +++- .../test_commit_range_documents.py | 176 ++++++++++++++++++ 3 files changed, 206 insertions(+), 8 deletions(-) diff --git a/cycode/cli/apps/scan/pre_receive/pre_receive_command.py b/cycode/cli/apps/scan/pre_receive/pre_receive_command.py index 3b85dc9e..f6265fd2 100644 --- a/cycode/cli/apps/scan/pre_receive/pre_receive_command.py +++ b/cycode/cli/apps/scan/pre_receive/pre_receive_command.py @@ -47,7 +47,9 @@ def pre_receive_command( timeout = configuration_manager.get_pre_receive_command_timeout(command_scan_type) with TimeoutAfter(timeout): branch_update_details = parse_pre_receive_input() - commit_range = calculate_pre_receive_commit_range(branch_update_details) + commit_range = calculate_pre_receive_commit_range( + repo_path=os.getcwd(), branch_update_details=branch_update_details + ) if not commit_range: logger.info( 'No new commits found for pushed branch, %s', diff --git a/cycode/cli/files_collector/commit_range_documents.py b/cycode/cli/files_collector/commit_range_documents.py index 8f8eccac..2ca54dd5 100644 --- a/cycode/cli/files_collector/commit_range_documents.py +++ b/cycode/cli/files_collector/commit_range_documents.py @@ -104,19 +104,27 @@ def collect_commit_range_diff_documents( return commit_documents_to_scan -def calculate_pre_receive_commit_range(branch_update_details: str) -> Optional[str]: +def calculate_pre_receive_commit_range(repo_path: str, branch_update_details: str) -> Optional[str]: end_commit = _get_end_commit_from_branch_update_details(branch_update_details) # branch is deleted, no need to perform scan if end_commit == consts.EMPTY_COMMIT_SHA: return None - start_commit = _get_oldest_unupdated_commit_for_branch(end_commit) + repo = git_proxy.get_repo(repo_path) + start_commit = _get_oldest_unupdated_commit_for_branch(repo, end_commit) # no new commit to update found if not start_commit: return None + # If the oldest not-yet-updated commit has no parent (root commit or orphaned history), + # using '~1' will fail. In that case, scan from the end commit, which effectively + # includes the entire history reachable from it (which is exactly what we need here). + + if not bool(repo.commit(start_commit).parents): + return f'{end_commit}' + return f'{start_commit}~1...{end_commit}' @@ -126,10 +134,10 @@ def _get_end_commit_from_branch_update_details(update_details: str) -> str: return end_commit -def _get_oldest_unupdated_commit_for_branch(commit: str) -> Optional[str]: +def _get_oldest_unupdated_commit_for_branch(repo: 'Repo', commit: str) -> Optional[str]: # get a list of commits by chronological order that are not in the remote repository yet # more info about rev-list command: https://git-scm.com/docs/git-rev-list - repo = git_proxy.get_repo(os.getcwd()) + not_updated_commits = repo.git.rev_list(commit, '--topo-order', '--reverse', '--not', '--all') commits = not_updated_commits.splitlines() @@ -199,8 +207,7 @@ def parse_pre_receive_input() -> str: :return: First branch update details (input's first line) """ - # FIXME(MarshalX): this blocks main thread forever if called outside of pre-receive hook - pre_receive_input = sys.stdin.read().strip() + pre_receive_input = _read_hook_input_from_stdin() if not pre_receive_input: raise ValueError( 'Pre receive input was not found. Make sure that you are using this command only in pre-receive hook' @@ -222,7 +229,7 @@ def parse_pre_push_input() -> str: :return: First, push update details (input's first line) """ # noqa: E501 - pre_push_input = sys.stdin.read().strip() + pre_push_input = _read_hook_input_from_stdin() if not pre_push_input: raise ValueError( 'Pre push input was not found. Make sure that you are using this command only in pre-push hook' @@ -232,6 +239,19 @@ def parse_pre_push_input() -> str: return pre_push_input.splitlines()[0] +def _read_hook_input_from_stdin() -> str: + """Read input from stdin when called from a hook. + + If called manually from the command line, return an empty string so it doesn't block the main thread. + + Returns: + Input from stdin + """ + if sys.stdin.isatty(): + return '' + return sys.stdin.read().strip() + + def _get_default_branches_for_merge_base(repo: 'Repo') -> list[str]: """Get a list of default branches to try for merge base calculation. diff --git a/tests/cli/files_collector/test_commit_range_documents.py b/tests/cli/files_collector/test_commit_range_documents.py index cd30d1eb..d27d24a3 100644 --- a/tests/cli/files_collector/test_commit_range_documents.py +++ b/tests/cli/files_collector/test_commit_range_documents.py @@ -12,13 +12,22 @@ from cycode.cli.files_collector.commit_range_documents import ( _get_default_branches_for_merge_base, calculate_pre_push_commit_range, + calculate_pre_receive_commit_range, get_diff_file_path, get_safe_head_reference_for_diff, parse_commit_range, parse_pre_push_input, + parse_pre_receive_input, ) from cycode.cli.utils.path_utils import get_path_by_os +DUMMY_SHA_0 = '0' * 40 +DUMMY_SHA_1 = '1' * 40 +DUMMY_SHA_2 = '2' * 40 +DUMMY_SHA_A = 'a' * 40 +DUMMY_SHA_B = 'b' * 40 +DUMMY_SHA_C = 'c' * 40 + @contextmanager def git_repository(path: str) -> Generator[Repo, None, None]: @@ -871,3 +880,170 @@ def test_single_commit_spec(self) -> None: parsed_from, parsed_to = parse_commit_range(a, temp_dir) assert (parsed_from, parsed_to) == (a, c) + + +class TestParsePreReceiveInput: + """Test the parse_pre_receive_input function with various pre-receive hook input scenarios.""" + + def test_parse_single_update_input(self) -> None: + """Test parsing a single branch update input.""" + pre_receive_input = f'{DUMMY_SHA_1} {DUMMY_SHA_2} refs/heads/main' + + with patch('sys.stdin', StringIO(pre_receive_input)): + result = parse_pre_receive_input() + assert result == pre_receive_input + + def test_parse_multiple_update_input_returns_first_line(self) -> None: + """Test parsing multiple branch updates returns only the first line.""" + pre_receive_input = f"""{DUMMY_SHA_0} {DUMMY_SHA_A} refs/heads/main +{DUMMY_SHA_B} {DUMMY_SHA_C} refs/heads/feature""" + + with patch('sys.stdin', StringIO(pre_receive_input)): + result = parse_pre_receive_input() + assert result == f'{DUMMY_SHA_0} {DUMMY_SHA_A} refs/heads/main' + + def test_parse_empty_input_raises_error(self) -> None: + """Test that empty input raises ValueError.""" + match = 'Pre receive input was not found' + with patch('sys.stdin', StringIO('')), pytest.raises(ValueError, match=match): + parse_pre_receive_input() + + +class TestCalculatePreReceiveCommitRange: + """Test the calculate_pre_receive_commit_range function with representative scenarios.""" + + def test_branch_deletion_returns_none(self) -> None: + """When end commit is all zeros (deletion), no scan is needed.""" + update_details = f'{DUMMY_SHA_A} {consts.EMPTY_COMMIT_SHA} refs/heads/feature' + assert calculate_pre_receive_commit_range(os.getcwd(), update_details) is None + + def test_no_new_commits_returns_none(self) -> None: + """When there are no commits not in remote, return None.""" + with tempfile.TemporaryDirectory() as server_dir: + server_repo = Repo.init(server_dir, bare=True) + try: + with tempfile.TemporaryDirectory() as work_dir: + work_repo = Repo.init(work_dir, b='main') + try: + # Create a single commit and push it to the server as main (end commit is already on a ref) + test_file = os.path.join(work_dir, 'file.txt') + with open(test_file, 'w') as f: + f.write('base') + work_repo.index.add(['file.txt']) + end_commit = work_repo.index.commit('initial') + + work_repo.create_remote('origin', server_dir) + work_repo.remotes.origin.push('main:main') + + update_details = f'{DUMMY_SHA_A} {end_commit.hexsha} refs/heads/main' + assert calculate_pre_receive_commit_range(server_dir, update_details) is None + finally: + work_repo.close() + finally: + server_repo.close() + + def test_returns_triple_dot_range_from_oldest_unupdated(self) -> None: + """Returns '~1...' when there are new commits to scan.""" + with tempfile.TemporaryDirectory() as server_dir: + server_repo = Repo.init(server_dir, bare=True) + try: + with tempfile.TemporaryDirectory() as work_dir: + work_repo = Repo.init(work_dir, b='main') + try: + # Create commit A and push it to server as main (server has A on a ref) + a_path = os.path.join(work_dir, 'a.txt') + with open(a_path, 'w') as f: + f.write('A') + work_repo.index.add(['a.txt']) + work_repo.index.commit('A') + + work_repo.create_remote('origin', server_dir) + work_repo.remotes.origin.push('main:main') + + # Create commits B and C locally (not yet on server ref) + b_path = os.path.join(work_dir, 'b.txt') + with open(b_path, 'w') as f: + f.write('B') + work_repo.index.add(['b.txt']) + b_commit = work_repo.index.commit('B') + + c_path = os.path.join(work_dir, 'c.txt') + with open(c_path, 'w') as f: + f.write('C') + work_repo.index.add(['c.txt']) + end_commit = work_repo.index.commit('C') + + # Push the objects to a temporary ref and then delete that ref on server, + # so the objects exist but are not reachable from any ref. + work_repo.remotes.origin.push(f'{end_commit.hexsha}:refs/tmp/hold') + Repo(server_dir).git.update_ref('-d', 'refs/tmp/hold') + + update_details = f'{DUMMY_SHA_A} {end_commit.hexsha} refs/heads/main' + result = calculate_pre_receive_commit_range(server_dir, update_details) + assert result == f'{b_commit.hexsha}~1...{end_commit.hexsha}' + finally: + work_repo.close() + finally: + server_repo.close() + + def test_initial_oldest_commit_without_parent_returns_single_commit_range(self) -> None: + """If oldest commit has no parent, avoid '~1' and scan from end commit only.""" + with tempfile.TemporaryDirectory() as server_dir: + server_repo = Repo.init(server_dir, bare=True) + try: + with tempfile.TemporaryDirectory() as work_dir: + work_repo = Repo.init(work_dir, b='main') + try: + # Create a single root commit locally + p = os.path.join(work_dir, 'root.txt') + with open(p, 'w') as f: + f.write('root') + work_repo.index.add(['root.txt']) + end_commit = work_repo.index.commit('root') + + work_repo.create_remote('origin', server_dir) + # Push objects to a temporary ref and delete it so server has objects but no refs + work_repo.remotes.origin.push(f'{end_commit.hexsha}:refs/tmp/hold') + Repo(server_dir).git.update_ref('-d', 'refs/tmp/hold') + + update_details = f'{DUMMY_SHA_A} {end_commit.hexsha} refs/heads/main' + result = calculate_pre_receive_commit_range(server_dir, update_details) + assert result == end_commit.hexsha + finally: + work_repo.close() + finally: + server_repo.close() + + def test_initial_oldest_commit_without_parent_with_two_commits_returns_single_commit_range(self) -> None: + """If there are two new commits and the oldest has no parent, avoid '~1' and scan from end commit only.""" + with tempfile.TemporaryDirectory() as server_dir: + server_repo = Repo.init(server_dir, bare=True) + try: + with tempfile.TemporaryDirectory() as work_dir: + work_repo = Repo.init(work_dir, b='main') + try: + # Create two commits locally: oldest has no parent, second on top + a_path = os.path.join(work_dir, 'a.txt') + with open(a_path, 'w') as f: + f.write('A') + work_repo.index.add(['a.txt']) + work_repo.index.commit('A') + + d_path = os.path.join(work_dir, 'd.txt') + with open(d_path, 'w') as f: + f.write('D') + work_repo.index.add(['d.txt']) + end_commit = work_repo.index.commit('D') + + work_repo.create_remote('origin', server_dir) + # Push objects to a temporary ref and delete it so server has objects but no refs + work_repo.remotes.origin.push(f'{end_commit.hexsha}:refs/tmp/hold') + Repo(server_dir).git.update_ref('-d', 'refs/tmp/hold') + + update_details = f'{consts.EMPTY_COMMIT_SHA} {end_commit.hexsha} refs/heads/main' + result = calculate_pre_receive_commit_range(server_dir, update_details) + assert result == end_commit.hexsha + finally: + work_repo.close() + finally: + server_repo.close() From a8b1e4ac48c5f52912b15c33ec1e1c35ecc03b8b Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Fri, 28 Nov 2025 10:37:00 +0000 Subject: [PATCH 215/257] CM-55806: update mac runners (#361) --- .github/workflows/build_executable.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 769449c1..ae14d3a2 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -1,7 +1,13 @@ -name: Build executable version of CLI and upload artifact. On dispatch event build the latest tag and upload to release assets +name: Build executable version of CLI and optionally upload artifact on: workflow_dispatch: + inputs: + publish: + description: 'Upload artifacts to release' + required: false + default: false + type: boolean push: branches: - main @@ -15,7 +21,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ ubuntu-22.04, macos-13, macos-14, windows-2022 ] + os: [ ubuntu-22.04, macos-15-intel, macos-15, windows-2022 ] mode: [ 'onefile', 'onedir' ] exclude: - os: ubuntu-22.04 @@ -200,7 +206,7 @@ jobs: path: dist - name: Upload files to release - if: ${{ github.event_name == 'workflow_dispatch' }} + if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish }} uses: svenstaro/upload-release-action@v2 with: file: dist/* From 63d06ffc307603c256029878a62e677f61b664b9 Mon Sep 17 00:00:00 2001 From: mikita-okuneu Date: Tue, 2 Dec 2025 14:18:24 +0100 Subject: [PATCH 216/257] CM-55782: Add OIDC authentication (#362) --- README.md | 19 ++- cycode/cli/app.py | 8 ++ cycode/cli/apps/auth/auth_common.py | 39 ++++++- .../cli/apps/configure/configure_command.py | 11 +- cycode/cli/apps/configure/prompts.py | 11 ++ cycode/cli/config.py | 1 + .../cli/user_settings/credentials_manager.py | 26 ++++- cycode/cli/utils/get_api_client.py | 32 +++++- cycode/cyclient/base_token_auth_client.py | 100 ++++++++++++++++ cycode/cyclient/client_creator.py | 34 +++++- cycode/cyclient/cycode_oidc_based_client.py | 24 ++++ cycode/cyclient/cycode_token_based_client.py | 89 ++------------- .../configure/test_configure_command.py | 108 ++++++++++++++++-- tests/conftest.py | 44 +++++++ tests/cyclient/test_oidc_based_client.py | 91 +++++++++++++++ 15 files changed, 530 insertions(+), 107 deletions(-) create mode 100644 cycode/cyclient/base_token_auth_client.py create mode 100644 cycode/cyclient/cycode_oidc_based_client.py create mode 100644 tests/cyclient/test_oidc_based_client.py diff --git a/README.md b/README.md index 0cf2dc76..991ba56c 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ To install the Cycode CLI application on your local machine, perform the followi ./cycode ``` -3. Finally authenticate the CLI. There are three methods to set the Cycode client ID and client secret: +3. Finally authenticate the CLI. There are three methods to set the Cycode client ID and credentials (client secret or OIDC ID token): - [cycode auth](#using-the-auth-command) (**Recommended**) - [cycode configure](#using-the-configure-command) @@ -164,11 +164,15 @@ To install the Cycode CLI application on your local machine, perform the followi `Cycode Client ID []: 7fe5346b-xxxx-xxxx-xxxx-55157625c72d` -5. Enter your Cycode Client Secret value. +5. Enter your Cycode Client Secret value (skip if you plan to use an OIDC ID token). `Cycode Client Secret []: c1e24929-xxxx-xxxx-xxxx-8b08c1839a2e` -6. If the values were entered successfully, you'll see the following message: +6. Enter your Cycode OIDC ID Token value (optional). + + `Cycode ID Token []: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...` + +7. If the values were entered successfully, you'll see the following message: `Successfully configured CLI credentials!` @@ -193,6 +197,12 @@ and export CYCODE_CLIENT_SECRET={your Cycode Secret Key} ``` +If your organization uses OIDC authentication, you can provide the ID token instead (or in addition): + +```bash +export CYCODE_ID_TOKEN={your Cycode OIDC ID token} +``` + #### On Windows 1. From the Control Panel, navigate to the System menu: @@ -207,7 +217,7 @@ export CYCODE_CLIENT_SECRET={your Cycode Secret Key} environments variables button -4. Create `CYCODE_CLIENT_ID` and `CYCODE_CLIENT_SECRET` variables with values matching your ID and Secret Key, respectively: +4. Create `CYCODE_CLIENT_ID` and `CYCODE_CLIENT_SECRET` variables with values matching your ID and Secret Key, respectively. If you authenticate via OIDC, add `CYCODE_ID_TOKEN` with your OIDC ID token value as well: environment variables window @@ -321,6 +331,7 @@ The following are the options and commands available with the Cycode CLI applica | `-o`, `--output [rich\|text\|json\|table]` | Specify the output type. The default is `rich`. | | `--client-id TEXT` | Specify a Cycode client ID for this specific scan execution. | | `--client-secret TEXT` | Specify a Cycode client secret for this specific scan execution. | +| `--id-token TEXT` | Specify a Cycode OIDC ID token for this specific scan execution. | | `--install-completion` | Install completion for the current shell.. | | `--show-completion [bash\|zsh\|fish\|powershell\|pwsh]` | Show completion for the specified shell, to copy it or customize the installation. | | `-h`, `--help` | Show options for given command. | diff --git a/cycode/cli/app.py b/cycode/cli/app.py index 04872b7d..3ef0b322 100644 --- a/cycode/cli/app.py +++ b/cycode/cli/app.py @@ -110,6 +110,13 @@ def app_callback( rich_help_panel=_AUTH_RICH_HELP_PANEL, ), ] = None, + id_token: Annotated[ + Optional[str], + typer.Option( + help='Specify a Cycode OIDC ID token for this specific scan execution.', + rich_help_panel=_AUTH_RICH_HELP_PANEL, + ), + ] = None, _: Annotated[ Optional[bool], typer.Option( @@ -152,6 +159,7 @@ def app_callback( ctx.obj['client_id'] = client_id ctx.obj['client_secret'] = client_secret + ctx.obj['id_token'] = id_token ctx.obj['progress_bar'] = get_progress_bar(hidden=no_progress_meter, sections=SCAN_PROGRESS_BAR_SECTIONS) diff --git a/cycode/cli/apps/auth/auth_common.py b/cycode/cli/apps/auth/auth_common.py index 96fec4cf..f4ea09d9 100644 --- a/cycode/cli/apps/auth/auth_common.py +++ b/cycode/cli/apps/auth/auth_common.py @@ -4,6 +4,7 @@ from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError, RequestHttpError 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.cycode_oidc_based_client import CycodeOidcBasedClient from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient if TYPE_CHECKING: @@ -13,9 +14,23 @@ def get_authorization_info(ctx: 'Context') -> Optional[AuthInfo]: printer = ctx.obj.get('console_printer') - client_id, client_secret = ctx.obj.get('client_id'), ctx.obj.get('client_secret') + client_id = ctx.obj.get('client_id') + client_secret = ctx.obj.get('client_secret') + id_token = ctx.obj.get('id_token') + + credentials_manager = CredentialsManager() + + auth_info = _try_oidc_authorization(ctx, printer, client_id, id_token) + if auth_info: + return auth_info + if not client_id or not client_secret: - client_id, client_secret = CredentialsManager().get_credentials() + stored_client_id, stored_id_token = credentials_manager.get_oidc_credentials() + auth_info = _try_oidc_authorization(ctx, printer, stored_client_id, stored_id_token) + if auth_info: + return auth_info + + client_id, client_secret = credentials_manager.get_credentials() if not client_id or not client_secret: return None @@ -32,3 +47,23 @@ def get_authorization_info(ctx: 'Context') -> Optional[AuthInfo]: printer.print_exception() return None + + +def _try_oidc_authorization( + ctx: 'Context', printer: any, client_id: Optional[str], id_token: Optional[str] +) -> Optional[AuthInfo]: + if not client_id or not id_token: + return None + + try: + access_token = CycodeOidcBasedClient(client_id, id_token).get_access_token() + if not access_token: + return None + + user_id, tenant_id = get_user_and_tenant_ids_from_access_token(access_token) + return AuthInfo(user_id=user_id, tenant_id=tenant_id) + except (RequestHttpError, HttpUnauthorizedError): + if ctx: + printer.print_exception() + + return None diff --git a/cycode/cli/apps/configure/configure_command.py b/cycode/cli/apps/configure/configure_command.py index 348e3ccb..a8759459 100644 --- a/cycode/cli/apps/configure/configure_command.py +++ b/cycode/cli/apps/configure/configure_command.py @@ -7,6 +7,7 @@ get_app_url_input, get_client_id_input, get_client_secret_input, + get_id_token_input, ) from cycode.cli.console import console from cycode.cli.utils.sentry import add_breadcrumb @@ -32,6 +33,7 @@ def configure_command() -> None: * APP URL: The base URL for Cycode's web application (for on-premise or EU installations) * Client ID: Your Cycode client ID for authentication * Client Secret: Your Cycode client secret for authentication + * ID Token: Your Cycode ID token for authentication Example usage: * `cycode configure`: Start interactive configuration @@ -55,15 +57,22 @@ def configure_command() -> None: config_updated = True current_client_id, current_client_secret = CREDENTIALS_MANAGER.get_credentials_from_file() + _, current_id_token = CREDENTIALS_MANAGER.get_oidc_credentials_from_file() client_id = get_client_id_input(current_client_id) client_secret = get_client_secret_input(current_client_secret) + id_token = get_id_token_input(current_id_token) credentials_updated = False if _should_update_value(current_client_id, client_id) or _should_update_value(current_client_secret, client_secret): credentials_updated = True CREDENTIALS_MANAGER.update_credentials(client_id, client_secret) + oidc_credentials_updated = False + if _should_update_value(current_client_id, client_id) or _should_update_value(current_id_token, id_token): + oidc_credentials_updated = True + CREDENTIALS_MANAGER.update_oidc_credentials(client_id, id_token) + if config_updated: console.print(get_urls_update_result_message()) - if credentials_updated: + if credentials_updated or oidc_credentials_updated: console.print(get_credentials_update_result_message()) diff --git a/cycode/cli/apps/configure/prompts.py b/cycode/cli/apps/configure/prompts.py index 3025688d..63fae12c 100644 --- a/cycode/cli/apps/configure/prompts.py +++ b/cycode/cli/apps/configure/prompts.py @@ -46,3 +46,14 @@ def get_api_url_input(current_api_url: Optional[str]) -> str: default = current_api_url return typer.prompt(text=prompt_text, default=default, type=str) + + +def get_id_token_input(current_id_token: Optional[str]) -> Optional[str]: + prompt_text = 'Cycode ID Token' + + prompt_suffix = ' []: ' + if current_id_token: + prompt_suffix = f' [{obfuscate_text(current_id_token)}]: ' + + new_id_token = typer.prompt(text=prompt_text, prompt_suffix=prompt_suffix, default='', show_default=False) + return new_id_token or current_id_token diff --git a/cycode/cli/config.py b/cycode/cli/config.py index 73491546..79a84fe2 100644 --- a/cycode/cli/config.py +++ b/cycode/cli/config.py @@ -5,3 +5,4 @@ # env vars CYCODE_CLIENT_ID_ENV_VAR_NAME = 'CYCODE_CLIENT_ID' CYCODE_CLIENT_SECRET_ENV_VAR_NAME = 'CYCODE_CLIENT_SECRET' +CYCODE_ID_TOKEN_ENV_VAR_NAME = 'CYCODE_ID_TOKEN' diff --git a/cycode/cli/user_settings/credentials_manager.py b/cycode/cli/user_settings/credentials_manager.py index 7af43569..32564b0e 100644 --- a/cycode/cli/user_settings/credentials_manager.py +++ b/cycode/cli/user_settings/credentials_manager.py @@ -2,7 +2,11 @@ from pathlib import Path from typing import Optional -from cycode.cli.config import CYCODE_CLIENT_ID_ENV_VAR_NAME, CYCODE_CLIENT_SECRET_ENV_VAR_NAME +from cycode.cli.config import ( + CYCODE_CLIENT_ID_ENV_VAR_NAME, + CYCODE_CLIENT_SECRET_ENV_VAR_NAME, + CYCODE_ID_TOKEN_ENV_VAR_NAME, +) from cycode.cli.user_settings.base_file_manager import BaseFileManager from cycode.cli.user_settings.jwt_creator import JwtCreator from cycode.cli.utils.sentry import setup_scope_from_access_token @@ -15,6 +19,7 @@ class CredentialsManager(BaseFileManager): CLIENT_ID_FIELD_NAME: str = 'cycode_client_id' CLIENT_SECRET_FIELD_NAME: str = 'cycode_client_secret' + ID_TOKEN_FIELD_NAME: str = 'cycode_id_token' ACCESS_TOKEN_FIELD_NAME: str = 'cycode_access_token' ACCESS_TOKEN_EXPIRES_IN_FIELD_NAME: str = 'cycode_access_token_expires_in' ACCESS_TOKEN_CREATOR_FIELD_NAME: str = 'cycode_access_token_creator' @@ -38,6 +43,25 @@ def get_credentials_from_file(self) -> tuple[Optional[str], Optional[str]]: client_secret = file_content.get(self.CLIENT_SECRET_FIELD_NAME) return client_id, client_secret + def get_oidc_credentials_from_file(self) -> tuple[Optional[str], Optional[str]]: + file_content = self.read_file() + client_id = file_content.get(self.CLIENT_ID_FIELD_NAME) + id_token = file_content.get(self.ID_TOKEN_FIELD_NAME) + return client_id, id_token + + def get_oidc_credentials(self) -> tuple[Optional[str], Optional[str]]: + client_id = os.getenv(CYCODE_CLIENT_ID_ENV_VAR_NAME) + id_token = os.getenv(CYCODE_ID_TOKEN_ENV_VAR_NAME) + + if client_id is not None and id_token is not None: + return client_id, id_token + + return self.get_oidc_credentials_from_file() + + def update_oidc_credentials(self, client_id: str, id_token: str) -> None: + file_content_to_update = {self.CLIENT_ID_FIELD_NAME: client_id, self.ID_TOKEN_FIELD_NAME: id_token} + self.write_content_to_file(file_content_to_update) + def update_credentials(self, client_id: str, client_secret: str) -> None: file_content_to_update = {self.CLIENT_ID_FIELD_NAME: client_id, self.CLIENT_SECRET_FIELD_NAME: client_secret} self.write_content_to_file(file_content_to_update) diff --git a/cycode/cli/utils/get_api_client.py b/cycode/cli/utils/get_api_client.py index ba98d937..5c712288 100644 --- a/cycode/cli/utils/get_api_client.py +++ b/cycode/cli/utils/get_api_client.py @@ -14,8 +14,22 @@ def _get_cycode_client( - create_client_func: callable, client_id: Optional[str], client_secret: Optional[str], hide_response_log: bool + create_client_func: callable, + client_id: Optional[str], + client_secret: Optional[str], + hide_response_log: bool, + id_token: Optional[str] = None, ) -> Union['ScanClient', 'ReportClient']: + if client_id and id_token: + return create_client_func(client_id, None, hide_response_log, id_token) + + if not client_id or not id_token: + oidc_client_id, oidc_id_token = _get_configured_oidc_credentials() + if oidc_client_id and oidc_id_token: + return create_client_func(oidc_client_id, None, hide_response_log, oidc_id_token) + if oidc_id_token and not oidc_client_id: + raise click.ClickException('Cycode client id needed for OIDC authentication.') + if not client_id or not client_secret: client_id, client_secret = _get_configured_credentials() if not client_id: @@ -23,28 +37,36 @@ def _get_cycode_client( if not client_secret: raise click.ClickException('Cycode client secret is needed.') - return create_client_func(client_id, client_secret, hide_response_log) + return create_client_func(client_id, client_secret, hide_response_log, None) def get_scan_cycode_client(ctx: 'typer.Context') -> 'ScanClient': client_id = ctx.obj.get('client_id') client_secret = ctx.obj.get('client_secret') + id_token = ctx.obj.get('id_token') hide_response_log = not ctx.obj.get('show_secret', False) - return _get_cycode_client(create_scan_client, client_id, client_secret, hide_response_log) + return _get_cycode_client(create_scan_client, client_id, client_secret, hide_response_log, id_token) def get_report_cycode_client(ctx: 'typer.Context', hide_response_log: bool = True) -> 'ReportClient': client_id = ctx.obj.get('client_id') client_secret = ctx.obj.get('client_secret') - return _get_cycode_client(create_report_client, client_id, client_secret, hide_response_log) + id_token = ctx.obj.get('id_token') + return _get_cycode_client(create_report_client, client_id, client_secret, hide_response_log, id_token) def get_import_sbom_cycode_client(ctx: 'typer.Context', hide_response_log: bool = True) -> 'ImportSbomClient': client_id = ctx.obj.get('client_id') client_secret = ctx.obj.get('client_secret') - return _get_cycode_client(create_import_sbom_client, client_id, client_secret, hide_response_log) + id_token = ctx.obj.get('id_token') + return _get_cycode_client(create_import_sbom_client, client_id, client_secret, hide_response_log, id_token) def _get_configured_credentials() -> tuple[str, str]: credentials_manager = CredentialsManager() return credentials_manager.get_credentials() + + +def _get_configured_oidc_credentials() -> tuple[Optional[str], Optional[str]]: + credentials_manager = CredentialsManager() + return credentials_manager.get_oidc_credentials() diff --git a/cycode/cyclient/base_token_auth_client.py b/cycode/cyclient/base_token_auth_client.py new file mode 100644 index 00000000..3f164836 --- /dev/null +++ b/cycode/cyclient/base_token_auth_client.py @@ -0,0 +1,100 @@ +from abc import ABC, abstractmethod +from threading import Lock +from typing import Any, Optional + +import arrow +from requests import Response + +from cycode.cli.user_settings.credentials_manager import CredentialsManager +from cycode.cli.user_settings.jwt_creator import JwtCreator +from cycode.cyclient.cycode_client import CycodeClient + +_NGINX_PLAIN_ERRORS = [ + b'Invalid JWT Token', + b'JWT Token Needed', + b'JWT Token validation failed', +] + + +class BaseTokenAuthClient(CycodeClient, ABC): + """Base client for token-based authentication flows with cached JWTs.""" + + def __init__(self, client_id: str) -> None: + super().__init__() + self.client_id = client_id + + self._credentials_manager = CredentialsManager() + # load cached access token + access_token, expires_in, creator = self._credentials_manager.get_access_token() + + self._access_token = self._expires_in = None + expected_creator = self._create_jwt_creator() + if creator == expected_creator: + # we must be sure that cached access token is created using the same client id and client secret. + # because client id and client secret could be passed via command, via env vars or via config file. + # we must not use cached access token if client id or client secret was changed. + self._access_token = access_token + self._expires_in = arrow.get(expires_in) if expires_in else None + + self._lock = Lock() + + def get_access_token(self) -> str: + with self._lock: + self.refresh_access_token_if_needed() + return self._access_token + + def invalidate_access_token(self, in_storage: bool = False) -> None: + self._access_token = None + self._expires_in = None + + if in_storage: + self._credentials_manager.update_access_token(None, None, None) + + def refresh_access_token_if_needed(self) -> None: + if self._access_token is None or self._expires_in is None or arrow.utcnow() >= self._expires_in: + self.refresh_access_token() + + def refresh_access_token(self) -> None: + auth_response = self._request_new_access_token() + self._access_token = auth_response['token'] + + self._expires_in = arrow.utcnow().shift(seconds=auth_response['expires_in'] * 0.8) + + jwt_creator = self._create_jwt_creator() + self._credentials_manager.update_access_token(self._access_token, self._expires_in.timestamp(), jwt_creator) + + def get_request_headers(self, additional_headers: Optional[dict] = None, without_auth: bool = False) -> dict: + headers = super().get_request_headers(additional_headers=additional_headers) + + if not without_auth: + headers = self._add_auth_header(headers) + + return headers + + def _add_auth_header(self, headers: dict) -> dict: + headers['Authorization'] = f'Bearer {self.get_access_token()}' + return headers + + def _execute( + self, + *args, + **kwargs, + ) -> Response: + response = super()._execute(*args, **kwargs) + + # backend returns 200 and plain text. no way to catch it with .raise_for_status() + nginx_error_response = any(response.content.startswith(plain_error) for plain_error in _NGINX_PLAIN_ERRORS) + if response.status_code == 200 and nginx_error_response: + # if cached token is invalid, try to refresh it and retry the request + self.refresh_access_token() + response = super()._execute(*args, **kwargs) + + return response + + @abstractmethod + def _create_jwt_creator(self) -> JwtCreator: + """Create a JwtCreator instance for the current credential type.""" + + @abstractmethod + def _request_new_access_token(self) -> dict[str, Any]: + """Return the authentication payload with token and expires_in.""" diff --git a/cycode/cyclient/client_creator.py b/cycode/cyclient/client_creator.py index 68845646..01ab6b59 100644 --- a/cycode/cyclient/client_creator.py +++ b/cycode/cyclient/client_creator.py @@ -1,6 +1,9 @@ +from typing import Optional + from cycode.cyclient.config import dev_mode from cycode.cyclient.config_dev import DEV_CYCODE_API_URL from cycode.cyclient.cycode_dev_based_client import CycodeDevBasedClient +from cycode.cyclient.cycode_oidc_based_client import CycodeOidcBasedClient from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient from cycode.cyclient.import_sbom_client import ImportSbomClient from cycode.cyclient.report_client import ReportClient @@ -8,22 +11,41 @@ from cycode.cyclient.scan_config_base import DefaultScanConfig, DevScanConfig -def create_scan_client(client_id: str, client_secret: str, hide_response_log: bool) -> ScanClient: +def create_scan_client( + client_id: str, client_secret: Optional[str] = None, hide_response_log: bool = False, id_token: Optional[str] = None +) -> ScanClient: if dev_mode: client = CycodeDevBasedClient(DEV_CYCODE_API_URL) scan_config = DevScanConfig() else: - client = CycodeTokenBasedClient(client_id, client_secret) + if id_token: + client = CycodeOidcBasedClient(client_id, id_token) + else: + client = CycodeTokenBasedClient(client_id, client_secret) scan_config = DefaultScanConfig() return ScanClient(client, scan_config, hide_response_log) -def create_report_client(client_id: str, client_secret: str, _: bool) -> ReportClient: - client = CycodeDevBasedClient(DEV_CYCODE_API_URL) if dev_mode else CycodeTokenBasedClient(client_id, client_secret) +def create_report_client( + client_id: str, client_secret: Optional[str] = None, _: bool = False, id_token: Optional[str] = None +) -> ReportClient: + if dev_mode: + client = CycodeDevBasedClient(DEV_CYCODE_API_URL) + elif id_token: + client = CycodeOidcBasedClient(client_id, id_token) + else: + client = CycodeTokenBasedClient(client_id, client_secret) return ReportClient(client) -def create_import_sbom_client(client_id: str, client_secret: str, _: bool) -> ImportSbomClient: - client = CycodeDevBasedClient(DEV_CYCODE_API_URL) if dev_mode else CycodeTokenBasedClient(client_id, client_secret) +def create_import_sbom_client( + client_id: str, client_secret: Optional[str] = None, _: bool = False, id_token: Optional[str] = None +) -> ImportSbomClient: + if dev_mode: + client = CycodeDevBasedClient(DEV_CYCODE_API_URL) + elif id_token: + client = CycodeOidcBasedClient(client_id, id_token) + else: + client = CycodeTokenBasedClient(client_id, client_secret) return ImportSbomClient(client) diff --git a/cycode/cyclient/cycode_oidc_based_client.py b/cycode/cyclient/cycode_oidc_based_client.py new file mode 100644 index 00000000..5208cf5d --- /dev/null +++ b/cycode/cyclient/cycode_oidc_based_client.py @@ -0,0 +1,24 @@ +from typing import Any + +from cycode.cli.user_settings.jwt_creator import JwtCreator +from cycode.cyclient.base_token_auth_client import BaseTokenAuthClient + + +class CycodeOidcBasedClient(BaseTokenAuthClient): + """Send requests with JWT obtained via OIDC ID token.""" + + def __init__(self, client_id: str, id_token: str) -> None: + self.id_token = id_token + super().__init__(client_id) + + def _request_new_access_token(self) -> dict[str, Any]: + auth_response = self.post( + url_path='api/v1/auth/oidc/api-token', + body={'client_id': self.client_id, 'id_token': self.id_token}, + without_auth=True, + hide_response_content_log=True, + ) + return auth_response.json() + + def _create_jwt_creator(self) -> JwtCreator: + return JwtCreator.create(self.client_id, self.id_token) diff --git a/cycode/cyclient/cycode_token_based_client.py b/cycode/cyclient/cycode_token_based_client.py index ef538c89..bc2dc66e 100644 --- a/cycode/cyclient/cycode_token_based_client.py +++ b/cycode/cyclient/cycode_token_based_client.py @@ -1,97 +1,24 @@ -from threading import Lock -from typing import Optional +from typing import Any -import arrow -from requests import Response - -from cycode.cli.user_settings.credentials_manager import CredentialsManager from cycode.cli.user_settings.jwt_creator import JwtCreator -from cycode.cyclient.cycode_client import CycodeClient - -_NGINX_PLAIN_ERRORS = [ - b'Invalid JWT Token', - b'JWT Token Needed', - b'JWT Token validation failed', -] +from cycode.cyclient.base_token_auth_client import BaseTokenAuthClient -class CycodeTokenBasedClient(CycodeClient): +class CycodeTokenBasedClient(BaseTokenAuthClient): """Send requests with JWT.""" def __init__(self, client_id: str, client_secret: str) -> None: - super().__init__() self.client_secret = client_secret - self.client_id = client_id - - self._credentials_manager = CredentialsManager() - # load cached access token - access_token, expires_in, creator = self._credentials_manager.get_access_token() - - self._access_token = self._expires_in = None - if creator == JwtCreator.create(client_id, client_secret): - # we must be sure that cached access token is created using the same client id and client secret. - # because client id and client secret could be passed via command, via env vars or via config file. - # we must not use cached access token if client id or client secret was changed. - self._access_token = access_token - self._expires_in = arrow.get(expires_in) if expires_in else None - - self._lock = Lock() - - def get_access_token(self) -> str: - with self._lock: - self.refresh_access_token_if_needed() - return self._access_token - - def invalidate_access_token(self, in_storage: bool = False) -> None: - self._access_token = None - self._expires_in = None - - if in_storage: - self._credentials_manager.update_access_token(None, None, None) - - def refresh_access_token_if_needed(self) -> None: - if self._access_token is None or self._expires_in is None or arrow.utcnow() >= self._expires_in: - self.refresh_access_token() + super().__init__(client_id) - def refresh_access_token(self) -> None: + def _request_new_access_token(self) -> dict[str, Any]: auth_response = self.post( url_path='api/v1/auth/api-token', body={'clientId': self.client_id, 'secret': self.client_secret}, without_auth=True, hide_response_content_log=True, ) - auth_response_data = auth_response.json() - - self._access_token = auth_response_data['token'] - self._expires_in = arrow.utcnow().shift(seconds=auth_response_data['expires_in'] * 0.8) - - jwt_creator = JwtCreator.create(self.client_id, self.client_secret) - self._credentials_manager.update_access_token(self._access_token, self._expires_in.timestamp(), jwt_creator) - - def get_request_headers(self, additional_headers: Optional[dict] = None, without_auth: bool = False) -> dict: - headers = super().get_request_headers(additional_headers=additional_headers) - - if not without_auth: - headers = self._add_auth_header(headers) - - return headers - - def _add_auth_header(self, headers: dict) -> dict: - headers['Authorization'] = f'Bearer {self.get_access_token()}' - return headers - - def _execute( - self, - *args, - **kwargs, - ) -> Response: - response = super()._execute(*args, **kwargs) - - # backend returns 200 and plain text. no way to catch it with .raise_for_status() - nginx_error_response = any(response.content.startswith(plain_error) for plain_error in _NGINX_PLAIN_ERRORS) - if response.status_code == 200 and nginx_error_response: - # if cached token is invalid, try to refresh it and retry the request - self.refresh_access_token() - response = super()._execute(*args, **kwargs) + return auth_response.json() - return response + def _create_jwt_creator(self) -> JwtCreator: + return JwtCreator.create(self.client_id, self.client_secret) diff --git a/tests/cli/commands/configure/test_configure_command.py b/tests/cli/commands/configure/test_configure_command.py index 5ed94c1d..0d763edd 100644 --- a/tests/cli/commands/configure/test_configure_command.py +++ b/tests/cli/commands/configure/test_configure_command.py @@ -14,11 +14,16 @@ def test_configure_command_no_exist_values_in_file(mocker: 'MockerFixture') -> N api_url_user_input = 'new api url' client_id_user_input = 'new client id' client_secret_user_input = 'new client secret' + id_token_user_input = 'new id token' mocker.patch( 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', return_value=(None, None), ) + mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_oidc_credentials_from_file', + return_value=(None, None), + ) mocker.patch( 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_api_url', return_value=None, @@ -31,12 +36,21 @@ def test_configure_command_no_exist_values_in_file(mocker: 'MockerFixture') -> N # side effect - multiple return values, each item in the list represents return of a call mocker.patch( 'typer.prompt', - side_effect=[api_url_user_input, app_url_user_input, client_id_user_input, client_secret_user_input], + side_effect=[ + api_url_user_input, + app_url_user_input, + client_id_user_input, + client_secret_user_input, + id_token_user_input, + ], ) mocked_update_credentials = mocker.patch( 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials' ) + mocked_update_oidc_credentials = mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_oidc_credentials' + ) mocked_update_api_base_url = mocker.patch( 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_api_base_url' ) @@ -49,6 +63,7 @@ def test_configure_command_no_exist_values_in_file(mocker: 'MockerFixture') -> N # Assert mocked_update_credentials.assert_called_once_with(client_id_user_input, client_secret_user_input) + mocked_update_oidc_credentials.assert_called_once_with(client_id_user_input, id_token_user_input) mocked_update_api_base_url.assert_called_once_with(api_url_user_input) mocked_update_app_base_url.assert_called_once_with(app_url_user_input) @@ -59,11 +74,16 @@ def test_configure_command_update_current_configs_in_files(mocker: 'MockerFixtur api_url_user_input = 'new api url' client_id_user_input = 'new client id' client_secret_user_input = 'new client secret' + id_token_user_input = 'new id token' mocker.patch( 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', return_value=('client id file', 'client secret file'), ) + mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_oidc_credentials_from_file', + return_value=('client id file', 'id token file'), + ) mocker.patch( 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_api_url', return_value='api url file', @@ -76,7 +96,13 @@ def test_configure_command_update_current_configs_in_files(mocker: 'MockerFixtur # side effect - multiple return values, each item in the list represents return of a call mocker.patch( 'typer.prompt', - side_effect=[api_url_user_input, app_url_user_input, client_id_user_input, client_secret_user_input], + side_effect=[ + api_url_user_input, + app_url_user_input, + client_id_user_input, + client_secret_user_input, + id_token_user_input, + ], ) mocked_update_credentials = mocker.patch( @@ -88,12 +114,16 @@ def test_configure_command_update_current_configs_in_files(mocker: 'MockerFixtur mocked_update_app_base_url = mocker.patch( 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_app_base_url' ) + mocker_update_oidc_credentials = mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_oidc_credentials' + ) # Act CliRunner().invoke(app, ['configure']) # Assert mocked_update_credentials.assert_called_once_with(client_id_user_input, client_secret_user_input) + mocker_update_oidc_credentials.assert_called_once_with(client_id_user_input, id_token_user_input) mocked_update_api_base_url.assert_called_once_with(api_url_user_input) mocked_update_app_base_url.assert_called_once_with(app_url_user_input) @@ -108,7 +138,7 @@ def test_set_credentials_update_only_client_id(mocker: 'MockerFixture') -> None: ) # side effect - multiple return values, each item in the list represents return of a call - mocker.patch('typer.prompt', side_effect=['', '', client_id_user_input, '']) + mocker.patch('typer.prompt', side_effect=['', '', client_id_user_input, '', '']) mocked_update_credentials = mocker.patch( 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials' ) @@ -131,7 +161,7 @@ def test_configure_command_update_only_client_secret(mocker: 'MockerFixture') -> ) # side effect - multiple return values, each item in the list represents return of a call - mocker.patch('typer.prompt', side_effect=['', '', '', client_secret_user_input]) + mocker.patch('typer.prompt', side_effect=['', '', '', client_secret_user_input, '']) mocked_update_credentials = mocker.patch( 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials' ) @@ -154,7 +184,7 @@ def test_configure_command_update_only_api_url(mocker: 'MockerFixture') -> None: ) # side effect - multiple return values, each item in the list represents return of a call - mocker.patch('typer.prompt', side_effect=[api_url_user_input, '', '', '']) + mocker.patch('typer.prompt', side_effect=[api_url_user_input, '', '', '', '']) mocked_update_api_base_url = mocker.patch( 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_api_base_url' ) @@ -166,6 +196,34 @@ def test_configure_command_update_only_api_url(mocker: 'MockerFixture') -> None: mocked_update_api_base_url.assert_called_once_with(api_url_user_input) +def test_configure_command_update_only_id_token(mocker: 'MockerFixture') -> None: + # Arrange + current_client_id = 'client id file' + current_id_token = 'old id token' + new_id_token = 'new id token' + + mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', + return_value=(current_client_id, 'client secret file'), + ) + mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_oidc_credentials_from_file', + return_value=(current_client_id, current_id_token), + ) + + mocker.patch('typer.prompt', side_effect=['', '', '', '', new_id_token]) + + mocked_update_oidc_credentials = mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_oidc_credentials' + ) + + # Act + CliRunner().invoke(app, ['configure']) + + # Assert + mocked_update_oidc_credentials.assert_called_once_with(current_client_id, new_id_token) + + def test_configure_command_should_not_update_credentials(mocker: 'MockerFixture') -> None: # Arrange client_id_user_input = '' @@ -177,7 +235,7 @@ def test_configure_command_should_not_update_credentials(mocker: 'MockerFixture' ) # side effect - multiple return values, each item in the list represents return of a call - mocker.patch('typer.prompt', side_effect=['', '', client_id_user_input, client_secret_user_input]) + mocker.patch('typer.prompt', side_effect=['', '', client_id_user_input, client_secret_user_input, '']) mocked_update_credentials = mocker.patch( 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials' ) @@ -204,7 +262,7 @@ def test_configure_command_should_not_update_config_file(mocker: 'MockerFixture' ) # side effect - multiple return values, each item in the list represents return of a call - mocker.patch('typer.prompt', side_effect=[api_url_user_input, app_url_user_input, '', '']) + mocker.patch('typer.prompt', side_effect=[api_url_user_input, app_url_user_input, '', '', '']) mocked_update_api_base_url = mocker.patch( 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_api_base_url' ) @@ -218,3 +276,39 @@ def test_configure_command_should_not_update_config_file(mocker: 'MockerFixture' # Assert assert not mocked_update_api_base_url.called assert not mocked_update_app_base_url.called + + +def test_configure_command_should_not_update_oidc_credentials(mocker: 'MockerFixture') -> None: + # Arrange + current_client_id = 'client id file' + current_client_secret = 'client secret file' + current_id_token = 'old id token' + + mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', + return_value=(current_client_id, current_client_secret), + ) + mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_oidc_credentials_from_file', + return_value=(current_client_id, current_id_token), + ) + mocker.patch( + 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_api_url', + return_value='api url file', + ) + mocker.patch( + 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_app_url', + return_value='app url file', + ) + + mocker.patch('typer.prompt', side_effect=['', '', '', '', '']) + + mocked_update_oidc_credentials = mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_oidc_credentials' + ) + + # Act + CliRunner().invoke(app, ['configure']) + + # Assert + mocked_update_oidc_credentials.assert_not_called() diff --git a/tests/conftest.py b/tests/conftest.py index 6fa50f55..f1df29dd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,7 @@ from cycode.cli.user_settings.credentials_manager import CredentialsManager from cycode.cyclient.client_creator import create_scan_client +from cycode.cyclient.cycode_oidc_based_client import CycodeOidcBasedClient from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient from cycode.cyclient.scan_client import ScanClient @@ -14,6 +15,7 @@ _CLIENT_ID = 'b1234568-0eaa-1234-beb8-6f0c12345678' _CLIENT_SECRET = 'a12345a-42b2-1234-3bdd-c0130123456' +_ID_TOKEN = 'eyJhbGciOiJSUzI1NiIsImtpZCI6IjEyMzQ1NiJ9.eyJzdWIiOiI4NzY1NDMyMSIsImF1ZCI6ImN5Y29kZSIsImV4cCI6MTUxNjIzOTAyMiwiaXNfb2lkYyI6MX0.Rrby2hPzsoMM3' # noqa: E501 CLI_ENV_VARS = {'CYCODE_CLIENT_ID': _CLIENT_ID, 'CYCODE_CLIENT_SECRET': _CLIENT_SECRET} @@ -45,6 +47,17 @@ def create_token_based_client( return CycodeTokenBasedClient(client_id, client_secret) +def create_oidc_based_client(client_id: Optional[str] = None, id_token: Optional[str] = None) -> CycodeOidcBasedClient: + CredentialsManager.FILE_NAME = 'unit-tests-credentials.yaml' + + if client_id is None: + client_id = _CLIENT_ID + if id_token is None: + id_token = _ID_TOKEN + + return CycodeOidcBasedClient(client_id, id_token) + + @pytest.fixture(scope='session') def token_based_client() -> CycodeTokenBasedClient: return create_token_based_client() @@ -74,3 +87,34 @@ def api_token_response(api_token_url: str) -> responses.Response: def api_token(token_based_client: CycodeTokenBasedClient, api_token_response: responses.Response) -> str: responses.add(api_token_response) return token_based_client.get_access_token() + + +@pytest.fixture(scope='session') +def oidc_based_client() -> CycodeOidcBasedClient: + return create_oidc_based_client() + + +@pytest.fixture(scope='session') +def oidc_api_token_url(oidc_based_client: CycodeOidcBasedClient) -> str: + return f'{oidc_based_client.api_url}/api/v1/auth/oidc/api-token' + + +@pytest.fixture(scope='session') +@responses.activate +def oidc_api_token(oidc_based_client: CycodeOidcBasedClient, oidc_api_token_response: responses.Response) -> str: + responses.add(oidc_api_token_response) + return oidc_based_client.get_access_token() + + +@pytest.fixture(scope='session') +def oidc_api_token_response(oidc_api_token_url: str) -> responses.Response: + return responses.Response( + method=responses.POST, + url=oidc_api_token_url, + json={ + 'token': _EXPECTED_API_TOKEN, + 'refresh_token': '12345678-0c68-1234-91ba-a13123456789', + 'expires_in': 86400, + }, + status=200, + ) diff --git a/tests/cyclient/test_oidc_based_client.py b/tests/cyclient/test_oidc_based_client.py new file mode 100644 index 00000000..070448b3 --- /dev/null +++ b/tests/cyclient/test_oidc_based_client.py @@ -0,0 +1,91 @@ +import arrow +import responses + +from cycode.cyclient.cycode_oidc_based_client import CycodeOidcBasedClient +from tests.conftest import _EXPECTED_API_TOKEN, create_oidc_based_client + + +@responses.activate +def test_access_token_new( + oidc_based_client: CycodeOidcBasedClient, oidc_api_token_response: responses.Response +) -> None: + responses.add(oidc_api_token_response) + + api_token = oidc_based_client.get_access_token() + + assert api_token == _EXPECTED_API_TOKEN + + +@responses.activate +def test_access_token_expired( + oidc_based_client: CycodeOidcBasedClient, oidc_api_token_response: responses.Response +) -> None: + responses.add(oidc_api_token_response) + + oidc_based_client.get_access_token() + + oidc_based_client._expires_in = arrow.utcnow().shift(hours=-1) + + api_token_refreshed = oidc_based_client.get_access_token() + + assert api_token_refreshed == _EXPECTED_API_TOKEN + + +def test_get_request_headers(oidc_based_client: CycodeOidcBasedClient, oidc_api_token: str) -> None: + expected_headers = { + **oidc_based_client.MANDATORY_HEADERS, + 'Authorization': f'Bearer {_EXPECTED_API_TOKEN}', + } + + assert oidc_based_client.get_request_headers() == expected_headers + + +@responses.activate +def test_access_token_cached( + oidc_based_client: CycodeOidcBasedClient, oidc_api_token_response: responses.Response +) -> None: + responses.add(oidc_api_token_response) + oidc_based_client.get_access_token() + + client2 = create_oidc_based_client() + assert client2._access_token == oidc_based_client._access_token + assert client2._expires_in == oidc_based_client._expires_in + + +@responses.activate +def test_access_token_cached_creator_changed( + oidc_based_client: CycodeOidcBasedClient, oidc_api_token_response: responses.Response +) -> None: + responses.add(oidc_api_token_response) + oidc_based_client.get_access_token() + + client2 = create_oidc_based_client('client_id2', 'different-token') + assert client2._access_token is None + assert client2._expires_in is None + + +@responses.activate +def test_access_token_invalidation( + oidc_based_client: CycodeOidcBasedClient, oidc_api_token_response: responses.Response +) -> None: + responses.add(oidc_api_token_response) + oidc_based_client.get_access_token() + + expected_access_token = oidc_based_client._access_token + expected_expires_in = oidc_based_client._expires_in + + oidc_based_client.invalidate_access_token() + assert oidc_based_client._access_token is None + assert oidc_based_client._expires_in is None + + client2 = create_oidc_based_client() + assert client2._access_token == expected_access_token + assert client2._expires_in == expected_expires_in + + client2.invalidate_access_token(in_storage=True) + assert client2._access_token is None + assert client2._expires_in is None + + client3 = create_oidc_based_client() + assert client3._access_token is None + assert client3._expires_in is None From 7749b00a25454733aa73fd8d11d3038d66629515 Mon Sep 17 00:00:00 2001 From: ronens88 <55343081+ronens88@users.noreply.github.com> Date: Fri, 19 Dec 2025 12:38:12 +0200 Subject: [PATCH 217/257] fix: include branch information in scan parameters for repository scans (#365) --- .../scan/repository/repository_command.py | 4 + cycode/cli/apps/scan/scan_parameters.py | 5 + .../cli/commands/scan/test_scan_parameters.py | 115 ++++++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 tests/cli/commands/scan/test_scan_parameters.py diff --git a/cycode/cli/apps/scan/repository/repository_command.py b/cycode/cli/apps/scan/repository/repository_command.py index 9692ccc4..f36c07e6 100644 --- a/cycode/cli/apps/scan/repository/repository_command.py +++ b/cycode/cli/apps/scan/repository/repository_command.py @@ -67,6 +67,10 @@ def repository_command( add_sca_dependencies_tree_documents_if_needed(ctx, scan_type, documents_to_scan) + # Store branch in context so it can be included in scan parameters + if branch: + ctx.obj['branch'] = branch + logger.debug('Found all relevant files for scanning %s', {'path': path, 'branch': branch}) scan_documents(ctx, documents_to_scan, get_scan_parameters(ctx, (str(path),))) except Exception as e: diff --git a/cycode/cli/apps/scan/scan_parameters.py b/cycode/cli/apps/scan/scan_parameters.py index 4d950880..58754e86 100644 --- a/cycode/cli/apps/scan/scan_parameters.py +++ b/cycode/cli/apps/scan/scan_parameters.py @@ -33,4 +33,9 @@ def get_scan_parameters(ctx: typer.Context, paths: Optional[tuple[str, ...]] = N ctx.obj['remote_url'] = remote_url scan_parameters['remote_url'] = remote_url + # Include branch information if available (for repository scans) + branch = ctx.obj.get('branch') + if branch: + scan_parameters['branch'] = branch + return scan_parameters diff --git a/tests/cli/commands/scan/test_scan_parameters.py b/tests/cli/commands/scan/test_scan_parameters.py new file mode 100644 index 00000000..6933e9bc --- /dev/null +++ b/tests/cli/commands/scan/test_scan_parameters.py @@ -0,0 +1,115 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from cycode.cli.apps.scan.scan_parameters import _get_default_scan_parameters, get_scan_parameters + + +@pytest.fixture +def mock_context() -> MagicMock: + """Create a mock typer.Context for testing.""" + ctx = MagicMock() + ctx.obj = { + 'monitor': False, + 'report': False, + 'package-vulnerabilities': True, + 'license-compliance': True, + } + ctx.info_name = 'test-command' + return ctx + + +def test_get_default_scan_parameters(mock_context: MagicMock) -> None: + """Test that default scan parameters are correctly extracted from context.""" + params = _get_default_scan_parameters(mock_context) + + assert params['monitor'] is False + assert params['report'] is False + assert params['package_vulnerabilities'] is True + assert params['license_compliance'] is True + assert params['command_type'] == 'test_command' # hyphens replaced with underscores + assert 'aggregation_id' in params + + +def test_get_scan_parameters_without_paths(mock_context: MagicMock) -> None: + """Test get_scan_parameters returns only default params when no paths provided.""" + params = get_scan_parameters(mock_context) + + assert 'paths' not in params + assert 'remote_url' not in params + assert 'branch' not in params + assert params['monitor'] is False + + +@patch('cycode.cli.apps.scan.scan_parameters.get_remote_url_scan_parameter') +def test_get_scan_parameters_with_paths(mock_get_remote_url: MagicMock, mock_context: MagicMock) -> None: + """Test get_scan_parameters includes paths and remote_url when paths provided.""" + mock_get_remote_url.return_value = 'https://github.com/example/repo.git' + paths = ('/path/to/repo',) + + params = get_scan_parameters(mock_context, paths) + + assert params['paths'] == paths + assert params['remote_url'] == 'https://github.com/example/repo.git' + assert mock_context.obj['remote_url'] == 'https://github.com/example/repo.git' + + +@patch('cycode.cli.apps.scan.scan_parameters.get_remote_url_scan_parameter') +def test_get_scan_parameters_includes_branch_when_set(mock_get_remote_url: MagicMock, mock_context: MagicMock) -> None: + """Test that branch is included in scan_parameters when set in context.""" + mock_get_remote_url.return_value = None + mock_context.obj['branch'] = 'feature-branch' + paths = ('/path/to/repo',) + + params = get_scan_parameters(mock_context, paths) + + assert params['branch'] == 'feature-branch' + + +@patch('cycode.cli.apps.scan.scan_parameters.get_remote_url_scan_parameter') +def test_get_scan_parameters_excludes_branch_when_not_set( + mock_get_remote_url: MagicMock, mock_context: MagicMock +) -> None: + """Test that branch is not included in scan_parameters when not set in context.""" + mock_get_remote_url.return_value = None + # Ensure branch is not in context + mock_context.obj.pop('branch', None) + paths = ('/path/to/repo',) + + params = get_scan_parameters(mock_context, paths) + + assert 'branch' not in params + + +@patch('cycode.cli.apps.scan.scan_parameters.get_remote_url_scan_parameter') +def test_get_scan_parameters_excludes_branch_when_none(mock_get_remote_url: MagicMock, mock_context: MagicMock) -> None: + """Test that branch is not included when explicitly set to None.""" + mock_get_remote_url.return_value = None + mock_context.obj['branch'] = None + paths = ('/path/to/repo',) + + params = get_scan_parameters(mock_context, paths) + + assert 'branch' not in params + + +@patch('cycode.cli.apps.scan.scan_parameters.get_remote_url_scan_parameter') +def test_get_scan_parameters_branch_with_various_names(mock_get_remote_url: MagicMock, mock_context: MagicMock) -> None: + """Test branch parameter works with various branch naming conventions.""" + mock_get_remote_url.return_value = None + paths = ('/path/to/repo',) + + # Test main branch + mock_context.obj['branch'] = 'main' + params = get_scan_parameters(mock_context, paths) + assert params['branch'] == 'main' + + # Test feature branch with slashes + mock_context.obj['branch'] = 'feature/add-new-functionality' + params = get_scan_parameters(mock_context, paths) + assert params['branch'] == 'feature/add-new-functionality' + + # Test branch with special characters + mock_context.obj['branch'] = 'release-v1.0.0' + params = get_scan_parameters(mock_context, paths) + assert params['branch'] == 'release-v1.0.0' From 700654f071d38b003a60af468d93c597b23cd160 Mon Sep 17 00:00:00 2001 From: erang-cycode Date: Mon, 5 Jan 2026 12:20:50 +0200 Subject: [PATCH 218/257] CM-55551: npm avoid restore when alternative lockfile exists (#367) --- .../sca/go/restore_go_dependencies.py | 2 +- .../sca/maven/restore_gradle_dependencies.py | 2 +- .../sca/maven/restore_maven_dependencies.py | 2 +- .../sca/npm/restore_npm_dependencies.py | 143 +++++++- .../sca/nuget/restore_nuget_dependencies.py | 2 +- .../sca/ruby/restore_ruby_dependencies.py | 2 +- .../sca/sbt/restore_sbt_dependencies.py | 2 +- .../files_collector/sca/sca_file_collector.py | 6 +- tests/cli/files_collector/sca/npm/__init__.py | 0 .../sca/npm/test_restore_npm_dependencies.py | 347 ++++++++++++++++++ 10 files changed, 497 insertions(+), 11 deletions(-) create mode 100644 tests/cli/files_collector/sca/npm/__init__.py create mode 100644 tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py diff --git a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py index b57812b9..7c24e330 100644 --- a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py +++ b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py @@ -44,5 +44,5 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: def get_lock_file_name(self) -> str: return GO_RESTORE_FILE_NAME - def get_lock_file_names(self) -> str: + def get_lock_file_names(self) -> list[str]: return [self.get_lock_file_name()] diff --git a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py index 4e4f36eb..d2687bf6 100644 --- a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py @@ -41,7 +41,7 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: def get_lock_file_name(self) -> str: return BUILD_GRADLE_DEP_TREE_FILE_NAME - def get_lock_file_names(self) -> str: + def get_lock_file_names(self) -> list[str]: return [self.get_lock_file_name()] def get_working_directory(self, document: Document) -> Optional[str]: diff --git a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py index 50e55f10..22ec33db 100644 --- a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py @@ -34,7 +34,7 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: def get_lock_file_name(self) -> str: return join_paths('target', MAVEN_CYCLONE_DEP_TREE_FILE_NAME) - def get_lock_file_names(self) -> str: + def get_lock_file_names(self) -> list[str]: return [self.get_lock_file_name()] def try_restore_dependencies(self, document: Document) -> Optional[Document]: diff --git a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py index 120d7de7..9f8c0b66 100644 --- a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py +++ b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py @@ -1,13 +1,20 @@ import os +from typing import Optional import typer -from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.logger import get_logger + +logger = get_logger('NPM Restore Dependencies') NPM_PROJECT_FILE_EXTENSIONS = ['.json'] NPM_LOCK_FILE_NAME = 'package-lock.json' -NPM_LOCK_FILE_NAMES = [NPM_LOCK_FILE_NAME, 'yarn.lock', 'pnpm-lock.yaml', 'deno.lock'] +# Alternative lockfiles that should prevent npm install from running +ALTERNATIVE_LOCK_FILES = ['yarn.lock', 'pnpm-lock.yaml', 'deno.lock'] +NPM_LOCK_FILE_NAMES = [NPM_LOCK_FILE_NAME, *ALTERNATIVE_LOCK_FILES] NPM_MANIFEST_FILE_NAME = 'package.json' @@ -18,6 +25,127 @@ def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) def is_project(self, document: Document) -> bool: return any(document.path.endswith(ext) for ext in NPM_PROJECT_FILE_EXTENSIONS) + def _resolve_manifest_directory(self, document: Document) -> Optional[str]: + """Resolve the directory containing the manifest file. + + Uses the same path resolution logic as get_manifest_file_path() to ensure consistency. + Falls back to absolute_path or document.path if needed. + + Returns: + Directory path if resolved, None otherwise. + """ + manifest_file_path = self.get_manifest_file_path(document) + manifest_dir = os.path.dirname(manifest_file_path) if manifest_file_path else None + + # Fallback: if manifest_dir is empty or root, try using absolute_path or document.path + if not manifest_dir or manifest_dir == os.sep or manifest_dir == '.': + base_path = document.absolute_path if document.absolute_path else document.path + if base_path: + manifest_dir = os.path.dirname(base_path) + + return manifest_dir + + def _find_existing_lockfile(self, manifest_dir: str) -> tuple[Optional[str], list[str]]: + """Find the first existing lockfile in the manifest directory. + + Args: + manifest_dir: Directory to search for lockfiles. + + Returns: + Tuple of (lockfile_path if found, list of checked lockfiles with status). + """ + lock_file_paths = [os.path.join(manifest_dir, lock_file_name) for lock_file_name in NPM_LOCK_FILE_NAMES] + + existing_lock_file = None + checked_lockfiles = [] + for lock_file_path in lock_file_paths: + lock_file_name = os.path.basename(lock_file_path) + exists = os.path.isfile(lock_file_path) + checked_lockfiles.append(f'{lock_file_name}: {"exists" if exists else "not found"}') + if exists: + existing_lock_file = lock_file_path + break + + return existing_lock_file, checked_lockfiles + + def _create_document_from_lockfile(self, document: Document, lockfile_path: str) -> Optional[Document]: + """Create a Document from an existing lockfile. + + Args: + document: Original document (package.json). + lockfile_path: Path to the existing lockfile. + + Returns: + Document with lockfile content if successful, None otherwise. + """ + lock_file_name = os.path.basename(lockfile_path) + logger.info( + 'Skipping npm install: using existing lockfile, %s', + {'path': document.path, 'lockfile': lock_file_name, 'lockfile_path': lockfile_path}, + ) + + relative_restore_file_path = build_dep_tree_path(document.path, lock_file_name) + restore_file_content = get_file_content(lockfile_path) + + if restore_file_content is not None: + logger.debug( + 'Successfully loaded lockfile content, %s', + {'path': document.path, 'lockfile': lock_file_name, 'content_size': len(restore_file_content)}, + ) + return Document(relative_restore_file_path, restore_file_content, self.is_git_diff) + + logger.warning( + 'Lockfile exists but could not read content, %s', + {'path': document.path, 'lockfile': lock_file_name, 'lockfile_path': lockfile_path}, + ) + return None + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + """Override to prevent npm install when any lockfile exists. + + The base class uses document.absolute_path which might be None or incorrect. + We need to use the same path resolution logic as get_manifest_file_path() + to ensure we check for lockfiles in the correct location. + + If any lockfile exists (package-lock.json, pnpm-lock.yaml, yarn.lock, deno.lock), + we use it directly without running npm install to avoid generating invalid lockfiles. + """ + # Check if this is a project file first (same as base class caller does) + if not self.is_project(document): + logger.debug('Skipping restore: document is not recognized as npm project, %s', {'path': document.path}) + return None + + # Resolve the manifest directory + manifest_dir = self._resolve_manifest_directory(document) + if not manifest_dir: + logger.debug( + 'Cannot determine manifest directory, proceeding with base class restore flow, %s', + {'path': document.path}, + ) + return super().try_restore_dependencies(document) + + # Check for existing lockfiles + logger.debug( + 'Checking for existing lockfiles in directory, %s', {'directory': manifest_dir, 'path': document.path} + ) + existing_lock_file, checked_lockfiles = self._find_existing_lockfile(manifest_dir) + + logger.debug( + 'Lockfile check results, %s', + {'path': document.path, 'checked_lockfiles': ', '.join(checked_lockfiles)}, + ) + + # If any lockfile exists, use it directly without running npm install + if existing_lock_file: + return self._create_document_from_lockfile(document, existing_lock_file) + + # No lockfile exists, proceed with the normal restore flow which will run npm install + logger.info( + 'No existing lockfile found, proceeding with npm install to generate package-lock.json, %s', + {'path': document.path, 'directory': manifest_dir, 'checked_lockfiles': ', '.join(checked_lockfiles)}, + ) + return super().try_restore_dependencies(document) + def get_commands(self, manifest_file_path: str) -> list[list[str]]: return [ [ @@ -37,9 +165,16 @@ def get_restored_lock_file_name(self, restore_file_path: str) -> str: def get_lock_file_name(self) -> str: return NPM_LOCK_FILE_NAME - def get_lock_file_names(self) -> str: + def get_lock_file_names(self) -> list[str]: return NPM_LOCK_FILE_NAMES @staticmethod def prepare_manifest_file_path_for_command(manifest_file_path: str) -> str: - return manifest_file_path.replace(os.sep + NPM_MANIFEST_FILE_NAME, '') + # Remove package.json from the path + if manifest_file_path.endswith(NPM_MANIFEST_FILE_NAME): + # Use os.path.dirname to handle both Unix (/) and Windows (\) separators + # This is cross-platform and handles edge cases correctly + dir_path = os.path.dirname(manifest_file_path) + # If dir_path is empty or just '.', return an empty string (package.json in current dir) + return dir_path if dir_path and dir_path != '.' else '' + return manifest_file_path diff --git a/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py b/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py index 1e439bbd..95ced0ff 100644 --- a/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py +++ b/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py @@ -20,5 +20,5 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: def get_lock_file_name(self) -> str: return NUGET_LOCK_FILE_NAME - def get_lock_file_names(self) -> str: + def get_lock_file_names(self) -> list[str]: return [self.get_lock_file_name()] diff --git a/cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py b/cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py index 5e0fbe75..a8358270 100644 --- a/cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py +++ b/cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py @@ -15,5 +15,5 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: def get_lock_file_name(self) -> str: return RUBY_LOCK_FILE_NAME - def get_lock_file_names(self) -> str: + def get_lock_file_names(self) -> list[str]: return [self.get_lock_file_name()] diff --git a/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py b/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py index bb2a9626..c9529d6a 100644 --- a/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py +++ b/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py @@ -15,5 +15,5 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: def get_lock_file_name(self) -> str: return SBT_LOCK_FILE_NAME - def get_lock_file_names(self) -> str: + def get_lock_file_names(self) -> list[str]: return [self.get_lock_file_name()] diff --git a/cycode/cli/files_collector/sca/sca_file_collector.py b/cycode/cli/files_collector/sca/sca_file_collector.py index 0c206c66..41f70316 100644 --- a/cycode/cli/files_collector/sca/sca_file_collector.py +++ b/cycode/cli/files_collector/sca/sca_file_collector.py @@ -153,7 +153,11 @@ def _add_dependencies_tree_documents( continue if restore_dependencies_document.path in documents_to_add: - logger.debug('Duplicate document on restore for path: %s', restore_dependencies_document.path) + # Lockfile was already collected during file discovery, so we skip adding it again + logger.debug( + 'Lockfile already exists in scan, skipping duplicate document, %s', + {'path': restore_dependencies_document.path, 'source': 'restore'}, + ) else: logger.debug('Adding dependencies tree document, %s', restore_dependencies_document.path) documents_to_add[restore_dependencies_document.path] = restore_dependencies_document diff --git a/tests/cli/files_collector/sca/npm/__init__.py b/tests/cli/files_collector/sca/npm/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py new file mode 100644 index 00000000..af990085 --- /dev/null +++ b/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py @@ -0,0 +1,347 @@ +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.npm.restore_npm_dependencies import ( + ALTERNATIVE_LOCK_FILES, + NPM_LOCK_FILE_NAME, + RestoreNpmDependencies, +) +from cycode.cli.models import Document + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + """Create a mock typer context.""" + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_npm_dependencies(mock_ctx: typer.Context) -> RestoreNpmDependencies: + """Create a RestoreNpmDependencies instance.""" + return RestoreNpmDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestRestoreNpmDependenciesAlternativeLockfiles: + """Test that lockfiles prevent npm install from running.""" + + @pytest.mark.parametrize( + ('lockfile_name', 'lockfile_content', 'expected_content'), + [ + ('pnpm-lock.yaml', 'lockfileVersion: 5.4\n', 'lockfileVersion: 5.4\n'), + ('yarn.lock', '# yarn lockfile v1\n', '# yarn lockfile v1\n'), + ('deno.lock', '{"version": 2}\n', '{"version": 2}\n'), + ('package-lock.json', '{"lockfileVersion": 2}\n', '{"lockfileVersion": 2}\n'), + ], + ) + def test_lockfile_exists_should_skip_npm_install( + self, + restore_npm_dependencies: RestoreNpmDependencies, + tmp_path: Path, + lockfile_name: str, + lockfile_content: str, + expected_content: str, + ) -> None: + """Test that when any lockfile exists, npm install is skipped.""" + # Setup: Create package.json and lockfile + package_json_path = tmp_path / 'package.json' + lockfile_path = tmp_path / lockfile_name + + package_json_path.write_text('{"name": "test", "version": "1.0.0"}') + lockfile_path.write_text(lockfile_content) + + document = Document( + path=str(package_json_path), + content=package_json_path.read_text(), + absolute_path=str(package_json_path), + ) + + # Execute + result = restore_npm_dependencies.try_restore_dependencies(document) + + # Verify: Should return lockfile content without running npm install + assert result is not None + assert lockfile_name in result.path + assert result.content == expected_content + + def test_no_lockfile_exists_should_proceed_with_normal_flow( + self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path + ) -> None: + """Test that when no lockfile exists, normal flow proceeds (will run npm install).""" + # Setup: Create only package.json (no lockfile) + package_json_path = tmp_path / 'package.json' + package_json_path.write_text('{"name": "test", "version": "1.0.0"}') + + document = Document( + path=str(package_json_path), + content=package_json_path.read_text(), + absolute_path=str(package_json_path), + ) + + # Mock the base class's try_restore_dependencies to verify it's called + with patch.object( + restore_npm_dependencies.__class__.__bases__[0], + 'try_restore_dependencies', + return_value=None, + ) as mock_super: + # Execute + restore_npm_dependencies.try_restore_dependencies(document) + + # Verify: Should call parent's try_restore_dependencies (which will run npm install) + mock_super.assert_called_once_with(document) + + +class TestRestoreNpmDependenciesPathResolution: + """Test path resolution scenarios.""" + + @pytest.mark.parametrize( + 'has_absolute_path', + [True, False], + ) + def test_path_resolution_with_different_path_types( + self, + restore_npm_dependencies: RestoreNpmDependencies, + tmp_path: Path, + has_absolute_path: bool, + ) -> None: + """Test path resolution with absolute or relative paths.""" + package_json_path = tmp_path / 'package.json' + pnpm_lock_path = tmp_path / 'pnpm-lock.yaml' + + package_json_path.write_text('{"name": "test"}') + pnpm_lock_path.write_text('lockfileVersion: 5.4\n') + + document = Document( + path=str(package_json_path), + content='{"name": "test"}', + absolute_path=str(package_json_path) if has_absolute_path else None, + ) + + result = restore_npm_dependencies.try_restore_dependencies(document) + + assert result is not None + assert result.content == 'lockfileVersion: 5.4\n' + + def test_path_resolution_in_monitor_mode(self, tmp_path: Path) -> None: + """Test path resolution in monitor mode.""" + # Setup monitor mode context + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': True} + ctx.params = {'path': str(tmp_path)} + + restore_npm = RestoreNpmDependencies(ctx, is_git_diff=False, command_timeout=30) + + # Create files in a subdirectory + subdir = tmp_path / 'project' + subdir.mkdir() + package_json_path = subdir / 'package.json' + pnpm_lock_path = subdir / 'pnpm-lock.yaml' + + package_json_path.write_text('{"name": "test"}') + pnpm_lock_path.write_text('lockfileVersion: 5.4\n') + + # Document with a relative path + document = Document( + path='project/package.json', + content='{"name": "test"}', + absolute_path=str(package_json_path), + ) + + result = restore_npm.try_restore_dependencies(document) + + assert result is not None + assert result.content == 'lockfileVersion: 5.4\n' + + def test_path_resolution_with_nested_directory( + self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path + ) -> None: + """Test path resolution with a nested directory structure.""" + subdir = tmp_path / 'src' / 'app' + subdir.mkdir(parents=True) + + package_json_path = subdir / 'package.json' + pnpm_lock_path = subdir / 'pnpm-lock.yaml' + + package_json_path.write_text('{"name": "test"}') + pnpm_lock_path.write_text('lockfileVersion: 5.4\n') + + document = Document( + path=str(package_json_path), + content='{"name": "test"}', + absolute_path=str(package_json_path), + ) + + result = restore_npm_dependencies.try_restore_dependencies(document) + + assert result is not None + assert result.content == 'lockfileVersion: 5.4\n' + + +class TestRestoreNpmDependenciesEdgeCases: + """Test edge cases and error scenarios.""" + + def test_empty_lockfile_should_still_be_used( + self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path + ) -> None: + """Test that the empty lockfile is still used (prevents npm install).""" + package_json_path = tmp_path / 'package.json' + pnpm_lock_path = tmp_path / 'pnpm-lock.yaml' + + package_json_path.write_text('{"name": "test"}') + pnpm_lock_path.write_text('') # Empty file + + document = Document( + path=str(package_json_path), + content='{"name": "test"}', + absolute_path=str(package_json_path), + ) + + result = restore_npm_dependencies.try_restore_dependencies(document) + + # Should still return the empty lockfile (prevents npm install) + assert result is not None + assert result.content == '' + + def test_multiple_lockfiles_should_use_first_found( + self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path + ) -> None: + """Test that when multiple lockfiles exist, the first one found is used (package-lock.json has priority).""" + package_json_path = tmp_path / 'package.json' + package_lock_path = tmp_path / 'package-lock.json' + yarn_lock_path = tmp_path / 'yarn.lock' + pnpm_lock_path = tmp_path / 'pnpm-lock.yaml' + + package_json_path.write_text('{"name": "test"}') + package_lock_path.write_text('{"lockfileVersion": 2}\n') + yarn_lock_path.write_text('# yarn lockfile\n') + pnpm_lock_path.write_text('lockfileVersion: 5.4\n') + + document = Document( + path=str(package_json_path), + content='{"name": "test"}', + absolute_path=str(package_json_path), + ) + + result = restore_npm_dependencies.try_restore_dependencies(document) + + # Should use package-lock.json (first in the check order) + assert result is not None + assert 'package-lock.json' in result.path + assert result.content == '{"lockfileVersion": 2}\n' + + def test_multiple_alternative_lockfiles_should_use_first_found( + self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path + ) -> None: + """Test that when multiple alternative lockfiles exist (but no package-lock.json), + the first one found is used.""" + package_json_path = tmp_path / 'package.json' + yarn_lock_path = tmp_path / 'yarn.lock' + pnpm_lock_path = tmp_path / 'pnpm-lock.yaml' + + package_json_path.write_text('{"name": "test"}') + yarn_lock_path.write_text('# yarn lockfile\n') + pnpm_lock_path.write_text('lockfileVersion: 5.4\n') + + document = Document( + path=str(package_json_path), + content='{"name": "test"}', + absolute_path=str(package_json_path), + ) + + result = restore_npm_dependencies.try_restore_dependencies(document) + + # Should use yarn.lock (first in ALTERNATIVE_LOCK_FILES list) + assert result is not None + assert 'yarn.lock' in result.path + assert result.content == '# yarn lockfile\n' + + def test_lockfile_in_different_directory_should_not_be_found( + self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path + ) -> None: + """Test that lockfile in a different directory is not found.""" + package_json_path = tmp_path / 'package.json' + other_dir = tmp_path / 'other' + other_dir.mkdir() + pnpm_lock_path = other_dir / 'pnpm-lock.yaml' + + package_json_path.write_text('{"name": "test"}') + pnpm_lock_path.write_text('lockfileVersion: 5.4\n') + + document = Document( + path=str(package_json_path), + content='{"name": "test"}', + absolute_path=str(package_json_path), + ) + + # Mock the base class to verify it's called (since lockfile not found) + with patch.object( + restore_npm_dependencies.__class__.__bases__[0], + 'try_restore_dependencies', + return_value=None, + ) as mock_super: + restore_npm_dependencies.try_restore_dependencies(document) + + # Should proceed with normal flow since lockfile not in same directory + mock_super.assert_called_once_with(document) + + def test_non_json_file_should_not_trigger_restore( + self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path + ) -> None: + """Test that non-JSON files don't trigger restore.""" + text_file = tmp_path / 'readme.txt' + text_file.write_text('Some text') + + document = Document( + path=str(text_file), + content='Some text', + absolute_path=str(text_file), + ) + + # Should return None because is_project() returns False + result = restore_npm_dependencies.try_restore_dependencies(document) + + assert result is None + + +class TestRestoreNpmDependenciesHelperMethods: + """Test helper methods.""" + + def test_is_project_with_json_file(self, restore_npm_dependencies: RestoreNpmDependencies) -> None: + """Test is_project identifies JSON files correctly.""" + document = Document('package.json', '{}') + assert restore_npm_dependencies.is_project(document) is True + + document = Document('tsconfig.json', '{}') + assert restore_npm_dependencies.is_project(document) is True + + def test_is_project_with_non_json_file(self, restore_npm_dependencies: RestoreNpmDependencies) -> None: + """Test is_project returns False for non-JSON files.""" + document = Document('readme.txt', 'text') + assert restore_npm_dependencies.is_project(document) is False + + document = Document('script.js', 'code') + assert restore_npm_dependencies.is_project(document) is False + + def test_get_lock_file_name(self, restore_npm_dependencies: RestoreNpmDependencies) -> None: + """Test get_lock_file_name returns the correct name.""" + assert restore_npm_dependencies.get_lock_file_name() == NPM_LOCK_FILE_NAME + + def test_get_lock_file_names(self, restore_npm_dependencies: RestoreNpmDependencies) -> None: + """Test get_lock_file_names returns all lockfile names.""" + lock_file_names = restore_npm_dependencies.get_lock_file_names() + assert NPM_LOCK_FILE_NAME in lock_file_names + for alt_lock in ALTERNATIVE_LOCK_FILES: + assert alt_lock in lock_file_names + + def test_prepare_manifest_file_path_for_command(self, restore_npm_dependencies: RestoreNpmDependencies) -> None: + """Test prepare_manifest_file_path_for_command removes package.json from the path.""" + result = restore_npm_dependencies.prepare_manifest_file_path_for_command('/path/to/package.json') + assert result == '/path/to' + + result = restore_npm_dependencies.prepare_manifest_file_path_for_command('package.json') + assert result == '' From 348852cf5d52d127aeb87b8226e95cd3286d407b Mon Sep 17 00:00:00 2001 From: Mateusz Sterczewski Date: Wed, 7 Jan 2026 14:26:51 +0100 Subject: [PATCH 219/257] CM-55207: Fix Secrets documents collecting for diff scan (#368) --- cycode/cli/apps/scan/commit_range_scanner.py | 4 +- .../files_collector/commit_range_documents.py | 48 ++++++++++-- .../test_commit_range_documents.py | 76 ++++++++++++++++--- 3 files changed, 109 insertions(+), 19 deletions(-) diff --git a/cycode/cli/apps/scan/commit_range_scanner.py b/cycode/cli/apps/scan/commit_range_scanner.py index eb0296c8..85497d5f 100644 --- a/cycode/cli/apps/scan/commit_range_scanner.py +++ b/cycode/cli/apps/scan/commit_range_scanner.py @@ -186,7 +186,7 @@ def _scan_commit_range_documents( def _scan_sca_commit_range(ctx: typer.Context, repo_path: str, commit_range: str, **_) -> None: scan_parameters = get_scan_parameters(ctx, (repo_path,)) - from_commit_rev, to_commit_rev = parse_commit_range(commit_range, repo_path) + from_commit_rev, to_commit_rev, _ = parse_commit_range(commit_range, repo_path) from_commit_documents, to_commit_documents, _ = get_commit_range_modified_documents( ctx.obj['progress_bar'], ScanProgressBarSection.PREPARE_LOCAL_FILES, repo_path, from_commit_rev, to_commit_rev ) @@ -227,7 +227,7 @@ def _scan_secret_commit_range( def _scan_sast_commit_range(ctx: typer.Context, repo_path: str, commit_range: str, **_) -> None: scan_parameters = get_scan_parameters(ctx, (repo_path,)) - from_commit_rev, to_commit_rev = parse_commit_range(commit_range, repo_path) + from_commit_rev, to_commit_rev, _ = parse_commit_range(commit_range, repo_path) _, commit_documents, diff_documents = get_commit_range_modified_documents( ctx.obj['progress_bar'], ScanProgressBarSection.PREPARE_LOCAL_FILES, diff --git a/cycode/cli/files_collector/commit_range_documents.py b/cycode/cli/files_collector/commit_range_documents.py index 2ca54dd5..d92aea81 100644 --- a/cycode/cli/files_collector/commit_range_documents.py +++ b/cycode/cli/files_collector/commit_range_documents.py @@ -67,12 +67,17 @@ def collect_commit_range_diff_documents( commit_documents_to_scan = [] repo = git_proxy.get_repo(path) - total_commits_count = int(repo.git.rev_list('--count', commit_range)) - logger.debug('Calculating diffs for %s commits in the commit range %s', total_commits_count, commit_range) + + normalized_commit_range = normalize_commit_range(commit_range, path) + + total_commits_count = int(repo.git.rev_list('--count', normalized_commit_range)) + logger.debug( + 'Calculating diffs for %s commits in the commit range %s', total_commits_count, normalized_commit_range + ) progress_bar.set_section_length(ScanProgressBarSection.PREPARE_LOCAL_FILES, total_commits_count) - for scanned_commits_count, commit in enumerate(repo.iter_commits(rev=commit_range)): + for scanned_commits_count, commit in enumerate(repo.iter_commits(rev=normalized_commit_range)): if _does_reach_to_max_commits_to_scan_limit(commit_ids_to_scan, max_commits_count): logger.debug('Reached to max commits to scan count. Going to scan only %s last commits', max_commits_count) progress_bar.update(ScanProgressBarSection.PREPARE_LOCAL_FILES, total_commits_count - scanned_commits_count) @@ -96,7 +101,12 @@ def collect_commit_range_diff_documents( logger.debug( 'Found all relevant files in commit %s', - {'path': path, 'commit_range': commit_range, 'commit_id': commit_id}, + { + 'path': path, + 'commit_range': commit_range, + 'normalized_commit_range': normalized_commit_range, + 'commit_id': commit_id, + }, ) logger.debug('List of commit ids to scan, %s', {'commit_ids': commit_ids_to_scan}) @@ -428,8 +438,9 @@ def get_pre_commit_modified_documents( return git_head_documents, pre_committed_documents, diff_documents -def parse_commit_range(commit_range: str, path: str) -> tuple[Optional[str], Optional[str]]: +def parse_commit_range(commit_range: str, path: str) -> tuple[Optional[str], Optional[str], Optional[str]]: """Parses a git commit range string and returns the full SHAs for the 'from' and 'to' commits. + Also, it returns the separator in the commit range. Supports: - 'from..to' @@ -440,8 +451,10 @@ def parse_commit_range(commit_range: str, path: str) -> tuple[Optional[str], Opt """ repo = git_proxy.get_repo(path) + separator = '..' if '...' in commit_range: from_spec, to_spec = commit_range.split('...', 1) + separator = '...' elif '..' in commit_range: from_spec, to_spec = commit_range.split('..', 1) else: @@ -459,7 +472,28 @@ def parse_commit_range(commit_range: str, path: str) -> tuple[Optional[str], Opt # Use rev_parse to resolve each specifier to its full commit SHA from_commit_rev = repo.rev_parse(from_spec).hexsha to_commit_rev = repo.rev_parse(to_spec).hexsha - return from_commit_rev, to_commit_rev + return from_commit_rev, to_commit_rev, separator except git_proxy.get_git_command_error() as e: logger.warning("Failed to parse commit range '%s'", commit_range, exc_info=e) - return None, None + return None, None, None + + +def normalize_commit_range(commit_range: str, path: str) -> str: + """Normalize a commit range string to handle various formats consistently with all scan types. + + Returns: + A normalized commit range string suitable for Git operations (e.g., 'full_sha1..full_sha2') + """ + from_commit_rev, to_commit_rev, separator = parse_commit_range(commit_range, path) + if from_commit_rev is None or to_commit_rev is None: + logger.warning('Failed to parse commit range "%s", falling back to raw string.', commit_range) + return commit_range + + # Construct a normalized range string using the original separator for iter_commits + normalized_commit_range = f'{from_commit_rev}{separator}{to_commit_rev}' + logger.debug( + 'Normalized commit range "%s" to "%s"', + commit_range, + normalized_commit_range, + ) + return normalized_commit_range diff --git a/tests/cli/files_collector/test_commit_range_documents.py b/tests/cli/files_collector/test_commit_range_documents.py index d27d24a3..0779f678 100644 --- a/tests/cli/files_collector/test_commit_range_documents.py +++ b/tests/cli/files_collector/test_commit_range_documents.py @@ -13,6 +13,7 @@ _get_default_branches_for_merge_base, calculate_pre_push_commit_range, calculate_pre_receive_commit_range, + collect_commit_range_diff_documents, get_diff_file_path, get_safe_head_reference_for_diff, parse_commit_range, @@ -846,40 +847,40 @@ def test_two_dot_linear_history(self) -> None: with temporary_git_repository() as (temp_dir, repo): a, b, c = self._make_linear_history(repo, temp_dir) - parsed_from, parsed_to = parse_commit_range(f'{a}..{c}', temp_dir) - assert (parsed_from, parsed_to) == (a, c) + parsed_from, parsed_to, separator = parse_commit_range(f'{a}..{c}', temp_dir) + assert (parsed_from, parsed_to, separator) == (a, c, '..') def test_three_dot_linear_history(self) -> None: """For 'A...C' in linear history, expect (A,C).""" with temporary_git_repository() as (temp_dir, repo): a, b, c = self._make_linear_history(repo, temp_dir) - parsed_from, parsed_to = parse_commit_range(f'{a}...{c}', temp_dir) - assert (parsed_from, parsed_to) == (a, c) + parsed_from, parsed_to, separator = parse_commit_range(f'{a}...{c}', temp_dir) + assert (parsed_from, parsed_to, separator) == (a, c, '...') def test_open_right_linear_history(self) -> None: """For 'A..', expect (A,HEAD=C).""" with temporary_git_repository() as (temp_dir, repo): a, b, c = self._make_linear_history(repo, temp_dir) - parsed_from, parsed_to = parse_commit_range(f'{a}..', temp_dir) - assert (parsed_from, parsed_to) == (a, c) + parsed_from, parsed_to, separator = parse_commit_range(f'{a}..', temp_dir) + assert (parsed_from, parsed_to, separator) == (a, c, '..') def test_open_left_linear_history(self) -> None: """For '..C' where HEAD==C, expect (HEAD=C,C).""" with temporary_git_repository() as (temp_dir, repo): a, b, c = self._make_linear_history(repo, temp_dir) - parsed_from, parsed_to = parse_commit_range(f'..{c}', temp_dir) - assert (parsed_from, parsed_to) == (c, c) + parsed_from, parsed_to, separator = parse_commit_range(f'..{c}', temp_dir) + assert (parsed_from, parsed_to, separator) == (c, c, '..') def test_single_commit_spec(self) -> None: """For 'A', expect (A,HEAD=C).""" with temporary_git_repository() as (temp_dir, repo): a, b, c = self._make_linear_history(repo, temp_dir) - parsed_from, parsed_to = parse_commit_range(a, temp_dir) - assert (parsed_from, parsed_to) == (a, c) + parsed_from, parsed_to, separator = parse_commit_range(a, temp_dir) + assert (parsed_from, parsed_to, separator) == (a, c, '..') class TestParsePreReceiveInput: @@ -1047,3 +1048,58 @@ def test_initial_oldest_commit_without_parent_with_two_commits_returns_single_co work_repo.close() finally: server_repo.close() + + +class TestCollectCommitRangeDiffDocuments: + """Test the collect_commit_range_diff_documents function with various commit range formats.""" + + def test_collect_with_various_commit_range_formats(self) -> None: + """Test that different commit range formats are normalized and work correctly.""" + with temporary_git_repository() as (temp_dir, repo): + # Create three commits + a_file = os.path.join(temp_dir, 'a.txt') + with open(a_file, 'w') as f: + f.write('A') + repo.index.add(['a.txt']) + a_commit = repo.index.commit('A') + + with open(a_file, 'a') as f: + f.write('B') + repo.index.add(['a.txt']) + b_commit = repo.index.commit('B') + + with open(a_file, 'a') as f: + f.write('C') + repo.index.add(['a.txt']) + c_commit = repo.index.commit('C') + + # Create mock context + mock_ctx = Mock() + mock_progress_bar = Mock() + mock_progress_bar.set_section_length = Mock() + mock_progress_bar.update = Mock() + mock_ctx.obj = {'progress_bar': mock_progress_bar} + + # Test two-dot range - should collect documents from commits B and C (2 commits, 2 documents) + commit_range = f'{a_commit.hexsha}..{c_commit.hexsha}' + documents = collect_commit_range_diff_documents(mock_ctx, temp_dir, commit_range) + assert len(documents) == 2, f'Expected 2 documents from range A..C, got {len(documents)}' + commit_ids_in_documents = {doc.unique_id for doc in documents if doc.unique_id} + assert b_commit.hexsha in commit_ids_in_documents + assert c_commit.hexsha in commit_ids_in_documents + + # Test three-dot range - should collect documents from commits B and C (2 commits, 2 documents) + commit_range = f'{a_commit.hexsha}...{c_commit.hexsha}' + documents = collect_commit_range_diff_documents(mock_ctx, temp_dir, commit_range) + assert len(documents) == 2, f'Expected 2 documents from range A...C, got {len(documents)}' + + # Test parent notation with three-dot - should collect document from commit C (1 commit, 1 document) + commit_range = f'{c_commit.hexsha}~1...{c_commit.hexsha}' + documents = collect_commit_range_diff_documents(mock_ctx, temp_dir, commit_range) + assert len(documents) == 1, f'Expected 1 document from range C~1...C, got {len(documents)}' + assert documents[0].unique_id == c_commit.hexsha + + # Test single commit spec - should be interpreted as A..HEAD (commits B and C, 2 documents) + commit_range = a_commit.hexsha + documents = collect_commit_range_diff_documents(mock_ctx, temp_dir, commit_range) + assert len(documents) == 2, f'Expected 2 documents from single commit A, got {len(documents)}' From bcc8506d112591f7501016c1205a2c2db3790b7a Mon Sep 17 00:00:00 2001 From: Mateusz Sterczewski Date: Fri, 9 Jan 2026 12:48:52 +0100 Subject: [PATCH 220/257] CM-55207-Fix commit range parsing for empty remote (#371) --- cycode/cli/consts.py | 1 + .../files_collector/commit_range_documents.py | 20 +++++- .../test_commit_range_documents.py | 61 +++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 1b1497bd..e06f9b00 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -269,6 +269,7 @@ # git consts COMMIT_DIFF_DELETED_FILE_CHANGE_TYPE = 'D' +COMMIT_RANGE_ALL_COMMITS = '--all' GIT_HEAD_COMMIT_REV = 'HEAD' GIT_EMPTY_TREE_OBJECT = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' EMPTY_COMMIT_SHA = '0000000000000000000000000000000000000000' diff --git a/cycode/cli/files_collector/commit_range_documents.py b/cycode/cli/files_collector/commit_range_documents.py index d92aea81..a4a1a784 100644 --- a/cycode/cli/files_collector/commit_range_documents.py +++ b/cycode/cli/files_collector/commit_range_documents.py @@ -351,10 +351,10 @@ def calculate_pre_push_commit_range(push_update_details: str) -> Optional[str]: return f'{merge_base}..{local_object_name}' logger.debug('Failed to find merge base with any default branch') - return '--all' + return consts.COMMIT_RANGE_ALL_COMMITS except Exception as e: logger.debug('Failed to get repo for pre-push commit range calculation: %s', exc_info=e) - return '--all' + return consts.COMMIT_RANGE_ALL_COMMITS # If deleting a branch (local_object_name is all zeros), no need to scan if local_object_name == consts.EMPTY_COMMIT_SHA: @@ -448,9 +448,25 @@ def parse_commit_range(commit_range: str, path: str) -> tuple[Optional[str], Opt - 'commit' (interpreted as 'commit..HEAD') - '..to' (interpreted as 'HEAD..to') - 'from..' (interpreted as 'from..HEAD') + - '--all' (interpreted as 'first_commit..HEAD' to scan all commits) """ repo = git_proxy.get_repo(path) + # Handle '--all' special case: scan all commits from first to HEAD + # Usually represents an empty remote repository + if commit_range == consts.COMMIT_RANGE_ALL_COMMITS: + try: + head_commit = repo.rev_parse(consts.GIT_HEAD_COMMIT_REV).hexsha + all_commits = repo.git.rev_list('--reverse', head_commit).strip() + if all_commits: + first_commit = all_commits.splitlines()[0] + return first_commit, head_commit, '..' + logger.warning("No commits found for range '%s'", commit_range) + return None, None, None + except Exception as e: + logger.warning("Failed to parse commit range '%s'", commit_range, exc_info=e) + return None, None, None + separator = '..' if '...' in commit_range: from_spec, to_spec = commit_range.split('...', 1) diff --git a/tests/cli/files_collector/test_commit_range_documents.py b/tests/cli/files_collector/test_commit_range_documents.py index 0779f678..501c1811 100644 --- a/tests/cli/files_collector/test_commit_range_documents.py +++ b/tests/cli/files_collector/test_commit_range_documents.py @@ -882,6 +882,67 @@ def test_single_commit_spec(self) -> None: parsed_from, parsed_to, separator = parse_commit_range(a, temp_dir) assert (parsed_from, parsed_to, separator) == (a, c, '..') + def test_parse_all_for_empty_remote_scenario(self) -> None: + """Test that '--all' is parsed correctly for empty remote repository. + This repository has one commit locally. + """ + with temporary_git_repository() as (temp_dir, repo): + # Create a local commit (simulating first commit to empty remote) + test_file = os.path.join(temp_dir, 'test.py') + with open(test_file, 'w') as f: + f.write("print('test')") + + repo.index.add(['test.py']) + commit = repo.index.commit('Initial commit') + + # Test that '--all' (returned by calculate_pre_push_commit_range for empty remote) + # can be parsed to a valid commit range + parsed_from, parsed_to, separator = parse_commit_range('--all', temp_dir) + + # Should return first commit to HEAD (which is the only commit in this case) + assert parsed_from == commit.hexsha + assert parsed_to == commit.hexsha + assert separator == '..' + + def test_parse_all_for_empty_remote_scenario_with_two_commits(self) -> None: + """Test that '--all' is parsed correctly for empty remote repository. + This repository has two commits locally. + """ + with temporary_git_repository() as (temp_dir, repo): + # Create first commit + test_file = os.path.join(temp_dir, 'test.py') + with open(test_file, 'w') as f: + f.write("print('test')") + + repo.index.add(['test.py']) + commit1 = repo.index.commit('First commit') + + # Create second commit + test_file2 = os.path.join(temp_dir, 'test2.py') + with open(test_file2, 'w') as f: + f.write("print('test2')") + + repo.index.add(['test2.py']) + commit2 = repo.index.commit('Second commit') + + # Test that '--all' returns first commit to HEAD (second commit) + parsed_from, parsed_to, separator = parse_commit_range('--all', temp_dir) + + # Should return first commit to HEAD (second commit) + assert parsed_from == commit1.hexsha # First commit + assert parsed_to == commit2.hexsha # HEAD (second commit) + assert separator == '..' + + def test_parse_all_with_empty_repository_returns_none(self) -> None: + """Test that '--all' returns None when repository has no commits.""" + with temporary_git_repository() as (temp_dir, repo): + # Empty repository with no commits + parsed_from, parsed_to, separator = parse_commit_range('--all', temp_dir) + # Should return None, None, None when HEAD doesn't exist + assert parsed_from is None + assert parsed_to is None + assert separator is None + class TestParsePreReceiveInput: """Test the parse_pre_receive_input function with various pre-receive hook input scenarios.""" From b3f7f9d509cde784411104f52fb85fb48b359f9f Mon Sep 17 00:00:00 2001 From: ixsploit <75364909+ixsploit@users.noreply.github.com> Date: Mon, 12 Jan 2026 14:07:49 +0100 Subject: [PATCH 221/257] chore(sca): bump cyclonedx-maven-plugin to 2.9.1 (#369) Co-authored-by: Philip Hayton --- .../cli/files_collector/sca/maven/restore_maven_dependencies.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py index 22ec33db..34499bdf 100644 --- a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py @@ -24,7 +24,7 @@ def is_project(self, document: Document) -> bool: return path.basename(document.path).split('/')[-1] == BUILD_MAVEN_FILE_NAME def get_commands(self, manifest_file_path: str) -> list[list[str]]: - command = ['mvn', 'org.cyclonedx:cyclonedx-maven-plugin:2.7.4:makeAggregateBom', '-f', manifest_file_path] + command = ['mvn', 'org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeAggregateBom', '-f', manifest_file_path] maven_settings_file = self.ctx.obj.get('maven_settings_file') if maven_settings_file: From 0947a93947cb3b3f13267392ed1df1dfeb06144e Mon Sep 17 00:00:00 2001 From: Mateusz Sterczewski Date: Wed, 14 Jan 2026 13:07:22 +0100 Subject: [PATCH 222/257] CM-55749-Add scan entrypoint marker file (#372) --- cycode/cli/apps/scan/code_scanner.py | 16 +++++++ cycode/cli/consts.py | 1 + tests/cli/commands/scan/test_code_scanner.py | 47 ++++++++++++++++++++ 3 files changed, 64 insertions(+) diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index 5b4c3e78..724009a5 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -1,3 +1,4 @@ +import os import time from platform import platform from typing import TYPE_CHECKING, Callable, Optional @@ -21,6 +22,7 @@ from cycode.cli.files_collector.sca.sca_file_collector import add_sca_dependencies_tree_documents_if_needed from cycode.cli.files_collector.zip_documents import zip_documents from cycode.cli.models import CliError, Document, LocalScanResult +from cycode.cli.utils.path_utils import get_absolute_path, get_path_by_os from cycode.cli.utils.progress_bar import ScanProgressBarSection from cycode.cli.utils.scan_batch import run_parallel_batched_scan from cycode.cli.utils.scan_utils import ( @@ -53,6 +55,20 @@ def scan_disk_files(ctx: typer.Context, paths: tuple[str, ...]) -> None: paths, is_cycodeignore_allowed=is_cycodeignore_allowed_by_scan_config(ctx), ) + + # Add entrypoint.cycode file at root path to mark the scan root (only for single path) + if len(paths) == 1: + root_path = paths[0] + absolute_root_path = get_absolute_path(root_path) + entrypoint_path = get_path_by_os(os.path.join(absolute_root_path, consts.CYCODE_ENTRYPOINT_FILENAME)) + entrypoint_document = Document( + entrypoint_path, + '', # Empty file content + is_git_diff_format=False, + absolute_path=entrypoint_path, + ) + documents.append(entrypoint_document) + add_sca_dependencies_tree_documents_if_needed(ctx, scan_type, documents) scan_documents(ctx, documents, get_scan_parameters(ctx, paths)) except Exception as e: diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index e06f9b00..0acd887e 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -18,6 +18,7 @@ IAC_SCAN_SUPPORTED_FILE_PREFIXES = ('dockerfile', 'containerfile') CYCODEIGNORE_FILENAME = '.cycodeignore' +CYCODE_ENTRYPOINT_FILENAME = 'entrypoint.cycode' SECRET_SCAN_FILE_EXTENSIONS_TO_IGNORE = ( '.DS_Store', diff --git a/tests/cli/commands/scan/test_code_scanner.py b/tests/cli/commands/scan/test_code_scanner.py index bf4d3574..0a43f1c1 100644 --- a/tests/cli/commands/scan/test_code_scanner.py +++ b/tests/cli/commands/scan/test_code_scanner.py @@ -1,6 +1,9 @@ import os +from os.path import normpath +from unittest.mock import MagicMock, Mock, patch from cycode.cli import consts +from cycode.cli.apps.scan.code_scanner import scan_disk_files from cycode.cli.files_collector.file_excluder import _is_file_relevant_for_sca_scan from cycode.cli.files_collector.path_documents import _generate_document from cycode.cli.models import Document @@ -72,3 +75,47 @@ def test_generate_document() -> None: assert isinstance(generated_tfplan_document, Document) assert generated_tfplan_document.path.endswith('.tf') assert generated_tfplan_document.is_git_diff_format == is_git_diff + + +@patch('cycode.cli.apps.scan.code_scanner.get_relevant_documents') +@patch('cycode.cli.apps.scan.code_scanner.scan_documents') +@patch('cycode.cli.apps.scan.code_scanner.get_scan_parameters') +def test_entrypoint_cycode_added_to_documents( + mock_get_scan_parameters: Mock, + mock_scan_documents: Mock, + mock_get_relevant_documents: Mock, +) -> None: + """Test that entrypoint.cycode file is added to documents in scan_disk_files.""" + # Arrange + mock_ctx = MagicMock() + mock_ctx.obj = { + 'scan_type': consts.SAST_SCAN_TYPE, + 'progress_bar': MagicMock(), + } + mock_get_scan_parameters.return_value = {} + + mock_documents = [ + Document('/test/path/file1.py', 'content1', is_git_diff_format=False), + Document('/test/path/file2.js', 'content2', is_git_diff_format=False), + ] + mock_get_relevant_documents.return_value = mock_documents.copy() + test_path = '/Users/test/repositories' + + # Act + scan_disk_files(mock_ctx, (test_path,)) + + # Assert + call_args = mock_scan_documents.call_args + documents_passed = call_args[0][1] + + # Verify entrypoint document was added + entrypoint_docs = [doc for doc in documents_passed if doc.path.endswith(consts.CYCODE_ENTRYPOINT_FILENAME)] + assert len(entrypoint_docs) == 1 + + entrypoint_doc = entrypoint_docs[0] + # Normalize paths for cross-platform compatibility + expected_path = normpath(os.path.join(os.path.abspath(test_path), consts.CYCODE_ENTRYPOINT_FILENAME)) + assert normpath(entrypoint_doc.path) == expected_path + assert entrypoint_doc.content == '' + assert entrypoint_doc.is_git_diff_format is False + assert normpath(entrypoint_doc.absolute_path) == normpath(entrypoint_doc.path) From 3625c5ccc96db873a975acc3036204aa2a4cbad2 Mon Sep 17 00:00:00 2001 From: Mateusz Sterczewski Date: Fri, 16 Jan 2026 08:38:02 +0100 Subject: [PATCH 223/257] CM-57916-Don't add entrypoint.cycode file when scanning single file (#373) --- cycode/cli/apps/scan/code_scanner.py | 19 +++++---- tests/cli/commands/scan/test_code_scanner.py | 43 ++++++++++++++++++++ 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index 724009a5..d3e325f3 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -56,18 +56,19 @@ def scan_disk_files(ctx: typer.Context, paths: tuple[str, ...]) -> None: is_cycodeignore_allowed=is_cycodeignore_allowed_by_scan_config(ctx), ) - # Add entrypoint.cycode file at root path to mark the scan root (only for single path) + # Add entrypoint.cycode file at root path to mark the scan root (only for single path that is a directory) if len(paths) == 1: root_path = paths[0] absolute_root_path = get_absolute_path(root_path) - entrypoint_path = get_path_by_os(os.path.join(absolute_root_path, consts.CYCODE_ENTRYPOINT_FILENAME)) - entrypoint_document = Document( - entrypoint_path, - '', # Empty file content - is_git_diff_format=False, - absolute_path=entrypoint_path, - ) - documents.append(entrypoint_document) + if os.path.isdir(absolute_root_path): + entrypoint_path = get_path_by_os(os.path.join(absolute_root_path, consts.CYCODE_ENTRYPOINT_FILENAME)) + entrypoint_document = Document( + entrypoint_path, + '', # Empty file content + is_git_diff_format=False, + absolute_path=entrypoint_path, + ) + documents.append(entrypoint_document) add_sca_dependencies_tree_documents_if_needed(ctx, scan_type, documents) scan_documents(ctx, documents, get_scan_parameters(ctx, paths)) diff --git a/tests/cli/commands/scan/test_code_scanner.py b/tests/cli/commands/scan/test_code_scanner.py index 0a43f1c1..8a9f60b7 100644 --- a/tests/cli/commands/scan/test_code_scanner.py +++ b/tests/cli/commands/scan/test_code_scanner.py @@ -80,7 +80,9 @@ def test_generate_document() -> None: @patch('cycode.cli.apps.scan.code_scanner.get_relevant_documents') @patch('cycode.cli.apps.scan.code_scanner.scan_documents') @patch('cycode.cli.apps.scan.code_scanner.get_scan_parameters') +@patch('cycode.cli.apps.scan.code_scanner.os.path.isdir') def test_entrypoint_cycode_added_to_documents( + mock_isdir: Mock, mock_get_scan_parameters: Mock, mock_scan_documents: Mock, mock_get_relevant_documents: Mock, @@ -93,6 +95,7 @@ def test_entrypoint_cycode_added_to_documents( 'progress_bar': MagicMock(), } mock_get_scan_parameters.return_value = {} + mock_isdir.return_value = True # Path is a directory mock_documents = [ Document('/test/path/file1.py', 'content1', is_git_diff_format=False), @@ -119,3 +122,43 @@ def test_entrypoint_cycode_added_to_documents( assert entrypoint_doc.content == '' assert entrypoint_doc.is_git_diff_format is False assert normpath(entrypoint_doc.absolute_path) == normpath(entrypoint_doc.path) + + +@patch('cycode.cli.apps.scan.code_scanner.get_relevant_documents') +@patch('cycode.cli.apps.scan.code_scanner.scan_documents') +@patch('cycode.cli.apps.scan.code_scanner.get_scan_parameters') +@patch('cycode.cli.apps.scan.code_scanner.os.path.isdir') +def test_entrypoint_cycode_not_added_for_single_file( + mock_isdir: Mock, + mock_get_scan_parameters: Mock, + mock_scan_documents: Mock, + mock_get_relevant_documents: Mock, +) -> None: + """Test that entrypoint.cycode file is NOT added when path is a single file.""" + # Arrange + mock_ctx = MagicMock() + mock_ctx.obj = { + 'scan_type': consts.SAST_SCAN_TYPE, + 'progress_bar': MagicMock(), + } + mock_get_scan_parameters.return_value = {} + mock_isdir.return_value = False # Path is a file, not a directory + + mock_documents = [ + Document('/test/path/file1.py', 'content1', is_git_diff_format=False), + ] + mock_get_relevant_documents.return_value = mock_documents.copy() + test_path = '/Users/test/file.py' + + # Act + scan_disk_files(mock_ctx, (test_path,)) + + # Assert + call_args = mock_scan_documents.call_args + documents_passed = call_args[0][1] + + # Verify entrypoint document was NOT added + entrypoint_docs = [doc for doc in documents_passed if doc.path.endswith(consts.CYCODE_ENTRYPOINT_FILENAME)] + assert len(entrypoint_docs) == 0 + # Verify only the original documents are present + assert len(documents_passed) == len(mock_documents) From 46cdd9e9ca9c74d951d9044379525611786e8fde Mon Sep 17 00:00:00 2001 From: Mateusz Sterczewski Date: Wed, 21 Jan 2026 14:54:51 +0100 Subject: [PATCH 224/257] CM-57848-Fix UTF encoding for Windows characters (#374) --- .../files_collector/models/in_memory_zip.py | 6 +++- .../cli/files_collector/test_in_memory_zip.py | 29 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 tests/cli/files_collector/test_in_memory_zip.py diff --git a/cycode/cli/files_collector/models/in_memory_zip.py b/cycode/cli/files_collector/models/in_memory_zip.py index 93ac4ac7..8bb9bf9e 100644 --- a/cycode/cli/files_collector/models/in_memory_zip.py +++ b/cycode/cli/files_collector/models/in_memory_zip.py @@ -26,7 +26,11 @@ def append(self, filename: str, unique_id: Optional[str], content: str) -> None: if unique_id: filename = concat_unique_id(filename, unique_id) - self.zip.writestr(filename, content) + # Encode content to bytes with error handling to handle surrogate characters + # that cannot be encoded to UTF-8. Use 'replace' to replace invalid characters + # with the Unicode replacement character (U+FFFD). + content_bytes = content.encode('utf-8', errors='replace') + self.zip.writestr(filename, content_bytes) def close(self) -> None: self.zip.close() diff --git a/tests/cli/files_collector/test_in_memory_zip.py b/tests/cli/files_collector/test_in_memory_zip.py new file mode 100644 index 00000000..d1790c7c --- /dev/null +++ b/tests/cli/files_collector/test_in_memory_zip.py @@ -0,0 +1,29 @@ +"""Tests for InMemoryZip class, specifically for handling surrogate characters and encoding issues.""" + +import zipfile +from io import BytesIO + +from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip + + +def test_append_with_surrogate_characters() -> None: + """Test that surrogate characters are handled gracefully without raising encoding errors.""" + # Surrogate characters (U+D800 to U+DFFF) cannot be encoded to UTF-8 directly + zip_file = InMemoryZip() + content = 'Normal text \udc96 more text' + + # Should not raise UnicodeEncodeError + zip_file.append('test.txt', None, content) + zip_file.close() + + # Verify the ZIP was created successfully + zip_data = zip_file.read() + assert len(zip_data) > 0 + + # Verify we can read it back and the surrogate was replaced + with zipfile.ZipFile(BytesIO(zip_data), 'r') as zf: + extracted = zf.read('test.txt').decode('utf-8') + assert 'Normal text' in extracted + assert 'more text' in extracted + # The surrogate should have been replaced with the replacement character + assert '\udc96' not in extracted From 3c73e3ded9d3a452a59ba5dae633baf2d1fc58c5 Mon Sep 17 00:00:00 2001 From: Mateusz Sterczewski Date: Thu, 22 Jan 2026 09:07:10 +0100 Subject: [PATCH 225/257] CM-57660-Remove PAT token from repository URL (#375) --- .../repository_url/repository_url_command.py | 11 ++- cycode/cli/apps/scan/remote_url_resolver.py | 11 ++- cycode/cli/utils/url_utils.py | 64 +++++++++++++++ cycode/cyclient/report_client.py | 10 ++- tests/utils/test_url_utils.py | 80 +++++++++++++++++++ 5 files changed, 172 insertions(+), 4 deletions(-) create mode 100644 cycode/cli/utils/url_utils.py create mode 100644 tests/utils/test_url_utils.py diff --git a/cycode/cli/apps/report/sbom/repository_url/repository_url_command.py b/cycode/cli/apps/report/sbom/repository_url/repository_url_command.py index 9e2f4885..e0955871 100644 --- a/cycode/cli/apps/report/sbom/repository_url/repository_url_command.py +++ b/cycode/cli/apps/report/sbom/repository_url/repository_url_command.py @@ -8,6 +8,10 @@ from cycode.cli.utils.get_api_client import get_report_cycode_client from cycode.cli.utils.progress_bar import SbomReportProgressBarSection from cycode.cli.utils.sentry import add_breadcrumb +from cycode.cli.utils.url_utils import sanitize_repository_url +from cycode.logger import get_logger + +logger = get_logger('Repository URL Command') def repository_url_command( @@ -28,8 +32,13 @@ def repository_url_command( start_scan_time = time.time() report_execution_id = -1 + # Sanitize repository URL to remove any embedded credentials/tokens before sending to API + sanitized_uri = sanitize_repository_url(uri) + if sanitized_uri != uri: + logger.debug('Sanitized repository URL to remove credentials') + try: - report_execution = client.request_sbom_report_execution(report_parameters, repository_url=uri) + report_execution = client.request_sbom_report_execution(report_parameters, repository_url=sanitized_uri) report_execution_id = report_execution.id create_sbom_report(progress_bar, client, report_execution_id, output_file, output_format) diff --git a/cycode/cli/apps/scan/remote_url_resolver.py b/cycode/cli/apps/scan/remote_url_resolver.py index 967e6ea0..870115e2 100644 --- a/cycode/cli/apps/scan/remote_url_resolver.py +++ b/cycode/cli/apps/scan/remote_url_resolver.py @@ -3,6 +3,7 @@ from cycode.cli import consts from cycode.cli.utils.git_proxy import git_proxy from cycode.cli.utils.shell_executor import shell +from cycode.cli.utils.url_utils import sanitize_repository_url from cycode.logger import get_logger logger = get_logger('Remote URL Resolver') @@ -102,7 +103,11 @@ def _try_get_git_remote_url(path: str) -> Optional[str]: repo = git_proxy.get_repo(path, search_parent_directories=True) remote_url = repo.remotes[0].config_reader.get('url') logger.debug('Found Git remote URL, %s', {'remote_url': remote_url, 'repo_path': repo.working_dir}) - return remote_url + # Sanitize URL to remove any embedded credentials/tokens before returning + sanitized_url = sanitize_repository_url(remote_url) + if sanitized_url != remote_url: + logger.debug('Sanitized repository URL to remove credentials') + return sanitized_url except Exception as e: logger.debug('Failed to get Git remote URL. Probably not a Git repository', exc_info=e) return None @@ -124,7 +129,9 @@ def get_remote_url_scan_parameter(paths: tuple[str, ...]) -> Optional[str]: # - len(paths)*2 Plastic SCM subprocess calls remote_url = _try_get_any_remote_url(path) if remote_url: - remote_urls.add(remote_url) + # URLs are already sanitized in _try_get_git_remote_url, but sanitize again as safety measure + sanitized_url = sanitize_repository_url(remote_url) + remote_urls.add(sanitized_url) if len(remote_urls) == 1: # we are resolving remote_url only if all paths belong to the same repo (identical remote URLs), diff --git a/cycode/cli/utils/url_utils.py b/cycode/cli/utils/url_utils.py new file mode 100644 index 00000000..91e50f77 --- /dev/null +++ b/cycode/cli/utils/url_utils.py @@ -0,0 +1,64 @@ +from typing import Optional +from urllib.parse import urlparse, urlunparse + +from cycode.logger import get_logger + +logger = get_logger('URL Utils') + + +def sanitize_repository_url(url: Optional[str]) -> Optional[str]: + """Remove credentials (username, password, tokens) from repository URL. + + This function sanitizes repository URLs to prevent sending PAT tokens or other + credentials to the API. It handles both HTTP/HTTPS URLs with embedded credentials + and SSH URLs (which are returned as-is since they don't contain credentials in the URL). + + Args: + url: Repository URL that may contain credentials (e.g., https://token@github.com/user/repo.git) + + Returns: + Sanitized URL without credentials (e.g., https://github.com/user/repo.git), or None if input is None + + Examples: + >>> sanitize_repository_url('https://token@github.com/user/repo.git') + 'https://github.com/user/repo.git' + >>> sanitize_repository_url('https://user:token@github.com/user/repo.git') + 'https://github.com/user/repo.git' + >>> sanitize_repository_url('git@github.com:user/repo.git') + 'git@github.com:user/repo.git' + >>> sanitize_repository_url(None) + None + """ + if not url: + return url + + # Handle SSH URLs - no credentials to remove + # ssh:// URLs have the format ssh://git@host/path + if url.startswith('ssh://'): + return url + # git@host:path format (scp-style) + if '@' in url and '://' not in url and url.startswith('git@'): + return url + + try: + parsed = urlparse(url) + # Remove username and password from netloc + # Reconstruct URL without credentials + sanitized_netloc = parsed.hostname + if parsed.port: + sanitized_netloc = f'{sanitized_netloc}:{parsed.port}' + + return urlunparse( + ( + parsed.scheme, + sanitized_netloc, + parsed.path, + parsed.params, + parsed.query, + parsed.fragment, + ) + ) + except Exception as e: + logger.debug('Failed to sanitize repository URL, returning original, %s', {'url': url, 'error': str(e)}) + # If parsing fails, return original URL to avoid breaking functionality + return url diff --git a/cycode/cyclient/report_client.py b/cycode/cyclient/report_client.py index e8107827..a55b5c40 100644 --- a/cycode/cyclient/report_client.py +++ b/cycode/cyclient/report_client.py @@ -6,8 +6,12 @@ from cycode.cli.exceptions.custom_exceptions import CycodeError from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip +from cycode.cli.utils.url_utils import sanitize_repository_url from cycode.cyclient import models from cycode.cyclient.cycode_client_base import CycodeClientBase +from cycode.logger import get_logger + +logger = get_logger('Report Client') @dataclasses.dataclass @@ -49,7 +53,11 @@ def request_sbom_report_execution( # entity type required only for zipped-file request_data = {'report_parameters': params.to_json(without_entity_type=zip_file is None)} if repository_url: - request_data['repository_url'] = repository_url + # Sanitize repository URL to remove any embedded credentials/tokens before sending to API + sanitized_url = sanitize_repository_url(repository_url) + if sanitized_url != repository_url: + logger.debug('Sanitized repository URL to remove credentials') + request_data['repository_url'] = sanitized_url request_args = { 'url_path': url_path, diff --git a/tests/utils/test_url_utils.py b/tests/utils/test_url_utils.py new file mode 100644 index 00000000..f7f6b6b0 --- /dev/null +++ b/tests/utils/test_url_utils.py @@ -0,0 +1,80 @@ +from cycode.cli.utils.url_utils import sanitize_repository_url + + +def test_sanitize_repository_url_with_token() -> None: + """Test that PAT tokens are removed from HTTPS URLs.""" + url = 'https://token@github.com/user/repo.git' + expected = 'https://github.com/user/repo.git' + assert sanitize_repository_url(url) == expected + + +def test_sanitize_repository_url_with_username_and_token() -> None: + """Test that username and token are removed from HTTPS URLs.""" + url = 'https://user:token@github.com/user/repo.git' + expected = 'https://github.com/user/repo.git' + assert sanitize_repository_url(url) == expected + + +def test_sanitize_repository_url_with_port() -> None: + """Test that URLs with ports are handled correctly.""" + url = 'https://token@github.com:443/user/repo.git' + expected = 'https://github.com:443/user/repo.git' + assert sanitize_repository_url(url) == expected + + +def test_sanitize_repository_url_ssh_format() -> None: + """Test that SSH URLs are returned as-is (no credentials in URL format).""" + url = 'git@github.com:user/repo.git' + assert sanitize_repository_url(url) == url + + +def test_sanitize_repository_url_ssh_protocol() -> None: + """Test that ssh:// URLs are returned as-is.""" + url = 'ssh://git@github.com/user/repo.git' + assert sanitize_repository_url(url) == url + + +def test_sanitize_repository_url_no_credentials() -> None: + """Test that URLs without credentials are returned unchanged.""" + url = 'https://github.com/user/repo.git' + assert sanitize_repository_url(url) == url + + +def test_sanitize_repository_url_none() -> None: + """Test that None input returns None.""" + assert sanitize_repository_url(None) is None + + +def test_sanitize_repository_url_empty_string() -> None: + """Test that empty string is returned as-is.""" + assert sanitize_repository_url('') == '' + + +def test_sanitize_repository_url_gitlab() -> None: + """Test that GitLab URLs are sanitized correctly.""" + url = 'https://oauth2:token@gitlab.com/user/repo.git' + expected = 'https://gitlab.com/user/repo.git' + assert sanitize_repository_url(url) == expected + + +def test_sanitize_repository_url_bitbucket() -> None: + """Test that Bitbucket URLs are sanitized correctly.""" + url = 'https://x-token-auth:token@bitbucket.org/user/repo.git' + expected = 'https://bitbucket.org/user/repo.git' + assert sanitize_repository_url(url) == expected + + +def test_sanitize_repository_url_with_path_and_query() -> None: + """Test that URLs with paths, query params, and fragments are preserved.""" + url = 'https://token@github.com/user/repo.git?ref=main#section' + expected = 'https://github.com/user/repo.git?ref=main#section' + assert sanitize_repository_url(url) == expected + + +def test_sanitize_repository_url_invalid_url() -> None: + """Test that invalid URLs are returned as-is (graceful degradation).""" + # This should not raise an exception, but return the original + url = 'not-a-valid-url' + result = sanitize_repository_url(url) + # Should return original since parsing fails + assert result == url From 26d13d2d2aaf4323b7e3d6a847a14a530af6f328 Mon Sep 17 00:00:00 2001 From: Mateusz Sterczewski Date: Mon, 26 Jan 2026 10:10:44 +0100 Subject: [PATCH 226/257] CM-57848-Fix UTF encoding when displaying code snippet (#376) --- cycode/cli/printers/tables/table_printer.py | 4 +- .../cli/printers/utils/code_snippet_syntax.py | 4 +- cycode/cli/printers/utils/rich_helpers.py | 4 +- cycode/cli/utils/string_utils.py | 9 ++ tests/cli/printers/__init__.py | 0 tests/cli/printers/utils/__init__.py | 0 .../printers/utils/test_rich_encoding_fix.py | 86 +++++++++++++++++++ 7 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 tests/cli/printers/__init__.py create mode 100644 tests/cli/printers/utils/__init__.py create mode 100644 tests/cli/printers/utils/test_rich_encoding_fix.py diff --git a/cycode/cli/printers/tables/table_printer.py b/cycode/cli/printers/tables/table_printer.py index 6a5dd198..4468ef9f 100644 --- a/cycode/cli/printers/tables/table_printer.py +++ b/cycode/cli/printers/tables/table_printer.py @@ -8,7 +8,7 @@ from cycode.cli.printers.tables.table_printer_base import TablePrinterBase from cycode.cli.printers.utils import is_git_diff_based_scan from cycode.cli.printers.utils.detection_ordering.common_ordering import sort_and_group_detections_from_scan_result -from cycode.cli.utils.string_utils import get_position_in_line, obfuscate_text +from cycode.cli.utils.string_utils import get_position_in_line, obfuscate_text, sanitize_text_for_encoding if TYPE_CHECKING: from cycode.cli.models import LocalScanResult @@ -96,6 +96,8 @@ def _enrich_table_with_detection_code_segment_values( if not self.show_secret: violation = obfuscate_text(violation) + violation = sanitize_text_for_encoding(violation) + table.add_cell(LINE_NUMBER_COLUMN, str(detection_line)) table.add_cell(COLUMN_NUMBER_COLUMN, str(detection_column)) table.add_cell(VIOLATION_LENGTH_COLUMN, f'{violation_length} chars') diff --git a/cycode/cli/printers/utils/code_snippet_syntax.py b/cycode/cli/printers/utils/code_snippet_syntax.py index 20f94d4e..57bc084e 100644 --- a/cycode/cli/printers/utils/code_snippet_syntax.py +++ b/cycode/cli/printers/utils/code_snippet_syntax.py @@ -5,7 +5,7 @@ from cycode.cli import consts from cycode.cli.console import _SYNTAX_HIGHLIGHT_THEME from cycode.cli.printers.utils import is_git_diff_based_scan -from cycode.cli.utils.string_utils import get_position_in_line, obfuscate_text +from cycode.cli.utils.string_utils import get_position_in_line, obfuscate_text, sanitize_text_for_encoding if TYPE_CHECKING: from cycode.cli.models import Document @@ -72,6 +72,7 @@ def _get_code_snippet_syntax_from_file( code_lines_to_render.append(line_content) code_to_render = '\n'.join(code_lines_to_render) + code_to_render = sanitize_text_for_encoding(code_to_render) return _get_syntax_highlighted_code( code=code_to_render, lexer=Syntax.guess_lexer(document.path, code=code_to_render), @@ -94,6 +95,7 @@ def _get_code_snippet_syntax_from_git_diff( violation = line_content[detection_position_in_line : detection_position_in_line + violation_length] line_content = line_content.replace(violation, obfuscate_text(violation)) + line_content = sanitize_text_for_encoding(line_content) return _get_syntax_highlighted_code( code=line_content, lexer='diff', diff --git a/cycode/cli/printers/utils/rich_helpers.py b/cycode/cli/printers/utils/rich_helpers.py index 52d2a0f2..6049b211 100644 --- a/cycode/cli/printers/utils/rich_helpers.py +++ b/cycode/cli/printers/utils/rich_helpers.py @@ -5,6 +5,7 @@ from rich.panel import Panel from cycode.cli.console import console +from cycode.cli.utils.string_utils import sanitize_text_for_encoding if TYPE_CHECKING: from rich.console import RenderableType @@ -20,8 +21,9 @@ def get_panel(renderable: 'RenderableType', title: str) -> Panel: def get_markdown_panel(markdown_text: str, title: str) -> Panel: + sanitized_text = sanitize_text_for_encoding(markdown_text.strip()) return get_panel( - Markdown(markdown_text.strip()), + Markdown(sanitized_text), title=title, ) diff --git a/cycode/cli/utils/string_utils.py b/cycode/cli/utils/string_utils.py index c3c0c6c6..06d3a51c 100644 --- a/cycode/cli/utils/string_utils.py +++ b/cycode/cli/utils/string_utils.py @@ -65,3 +65,12 @@ def shortcut_dependency_paths(dependency_paths_list: str) -> str: result += '\n' return result.rstrip().rstrip(',') + + +def sanitize_text_for_encoding(text: str) -> str: + """Sanitize text by replacing surrogate characters and invalid UTF-8 sequences. + + This prevents encoding errors when Rich tries to display the content, especially on Windows. + Surrogate characters (U+D800 to U+DFFF) cannot be encoded to UTF-8 and will cause errors. + """ + return text.encode('utf-8', errors='replace').decode('utf-8') diff --git a/tests/cli/printers/__init__.py b/tests/cli/printers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/printers/utils/__init__.py b/tests/cli/printers/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/printers/utils/test_rich_encoding_fix.py b/tests/cli/printers/utils/test_rich_encoding_fix.py new file mode 100644 index 00000000..721f1c6a --- /dev/null +++ b/tests/cli/printers/utils/test_rich_encoding_fix.py @@ -0,0 +1,86 @@ +"""Tests for Rich encoding fix to handle surrogate characters.""" + +from io import StringIO +from typing import Any +from unittest.mock import MagicMock + +from rich.console import Console + +from cycode.cli import consts +from cycode.cli.models import Document +from cycode.cli.printers.rich_printer import RichPrinter +from cycode.cyclient.models import Detection + + +def create_strict_encoding_console() -> tuple[Console, StringIO]: + """Create a Console that enforces strict UTF-8 encoding, simulating Windows console behavior. + + When Rich writes to the console, the file object needs to encode strings to bytes. + With errors='strict' (default for TextIOWrapper), this raises UnicodeEncodeError on surrogates. + This function simulates that behavior to test the encoding fix. + """ + buffer = StringIO() + + class StrictEncodingWrapper: + def __init__(self, file_obj: StringIO) -> None: + self._file = file_obj + + def write(self, text: str) -> int: + """Validate encoding before writing to simulate strict encoding behavior.""" + text.encode('utf-8') + return self._file.write(text) + + def flush(self) -> None: + self._file.flush() + + def isatty(self) -> bool: + return False + + def __getattr__(self, name: str) -> Any: + # Delegate all other attributes to the underlying file + return getattr(self._file, name) + + strict_file = StrictEncodingWrapper(buffer) + console = Console(file=strict_file, width=80, force_terminal=False) + return console, buffer + + +def test_rich_printer_handles_surrogate_characters_in_violation_card() -> None: + """Test that RichPrinter._print_violation_card() handles surrogate characters without errors. + + The error occurs in Rich's console._write_buffer() -> write() when console.print() is called. + On Windows with strict encoding, this raises UnicodeEncodeError on surrogates. + """ + surrogate_char = chr(0xDC96) + document_content = 'A' * 1236 + surrogate_char + 'B' * 100 + document = Document( + path='test.py', + content=document_content, + is_git_diff_format=False, + ) + + detection = Detection( + detection_type_id='test-id', + type='test-type', + message='Test message', + detection_details={ + 'description': 'Summary with ' + surrogate_char + ' surrogate character', + 'policy_display_name': 'Test Policy', + 'start_position': 1236, + 'length': 1, + 'line': 0, + }, + detection_rule_id='test-rule-id', + severity='Medium', + ) + + mock_ctx = MagicMock() + mock_ctx.obj = { + 'scan_type': consts.SAST_SCAN_TYPE, + 'show_secret': False, + } + mock_ctx.info_name = consts.SAST_SCAN_TYPE + + console, _ = create_strict_encoding_console() + printer = RichPrinter(mock_ctx, console, console) + printer._print_violation_card(document, detection, 1, 1) From 043ab3b8a1e57537bf74b85dc86009d8d9a3ebff Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Thu, 29 Jan 2026 17:45:29 +0200 Subject: [PATCH 227/257] CM-58022: cycode guardrails support cursor scan via hooks (#377) --- cycode/cli/app.py | 3 +- cycode/cli/apps/ai_guardrails/__init__.py | 19 + .../cli/apps/ai_guardrails/command_utils.py | 66 ++++ cycode/cli/apps/ai_guardrails/consts.py | 78 ++++ .../cli/apps/ai_guardrails/hooks_manager.py | 200 ++++++++++ .../cli/apps/ai_guardrails/install_command.py | 78 ++++ .../cli/apps/ai_guardrails/scan/__init__.py | 1 + cycode/cli/apps/ai_guardrails/scan/consts.py | 48 +++ .../cli/apps/ai_guardrails/scan/handlers.py | 341 +++++++++++++++++ cycode/cli/apps/ai_guardrails/scan/payload.py | 72 ++++ cycode/cli/apps/ai_guardrails/scan/policy.py | 85 +++++ .../ai_guardrails/scan/response_builders.py | 86 +++++ .../apps/ai_guardrails/scan/scan_command.py | 134 +++++++ cycode/cli/apps/ai_guardrails/scan/types.py | 54 +++ cycode/cli/apps/ai_guardrails/scan/utils.py | 72 ++++ .../cli/apps/ai_guardrails/status_command.py | 92 +++++ .../apps/ai_guardrails/uninstall_command.py | 73 ++++ cycode/cli/apps/scan/code_scanner.py | 2 +- cycode/cli/cli_types.py | 13 + cycode/cli/utils/get_api_client.py | 17 +- cycode/cli/utils/scan_utils.py | 24 ++ cycode/cyclient/ai_security_manager_client.py | 86 +++++ .../ai_security_manager_service_config.py | 27 ++ cycode/cyclient/client_creator.py | 20 + tests/cli/commands/ai_guardrails/__init__.py | 0 .../commands/ai_guardrails/scan/__init__.py | 0 .../ai_guardrails/scan/test_handlers.py | 361 ++++++++++++++++++ .../ai_guardrails/scan/test_payload.py | 135 +++++++ .../ai_guardrails/scan/test_policy.py | 199 ++++++++++ .../scan/test_response_builders.py | 79 ++++ .../commands/ai_guardrails/scan/test_utils.py | 113 ++++++ .../ai_guardrails/test_command_utils.py | 57 +++ 32 files changed, 2631 insertions(+), 4 deletions(-) create mode 100644 cycode/cli/apps/ai_guardrails/__init__.py create mode 100644 cycode/cli/apps/ai_guardrails/command_utils.py create mode 100644 cycode/cli/apps/ai_guardrails/consts.py create mode 100644 cycode/cli/apps/ai_guardrails/hooks_manager.py create mode 100644 cycode/cli/apps/ai_guardrails/install_command.py create mode 100644 cycode/cli/apps/ai_guardrails/scan/__init__.py create mode 100644 cycode/cli/apps/ai_guardrails/scan/consts.py create mode 100644 cycode/cli/apps/ai_guardrails/scan/handlers.py create mode 100644 cycode/cli/apps/ai_guardrails/scan/payload.py create mode 100644 cycode/cli/apps/ai_guardrails/scan/policy.py create mode 100644 cycode/cli/apps/ai_guardrails/scan/response_builders.py create mode 100644 cycode/cli/apps/ai_guardrails/scan/scan_command.py create mode 100644 cycode/cli/apps/ai_guardrails/scan/types.py create mode 100644 cycode/cli/apps/ai_guardrails/scan/utils.py create mode 100644 cycode/cli/apps/ai_guardrails/status_command.py create mode 100644 cycode/cli/apps/ai_guardrails/uninstall_command.py create mode 100644 cycode/cyclient/ai_security_manager_client.py create mode 100644 cycode/cyclient/ai_security_manager_service_config.py create mode 100644 tests/cli/commands/ai_guardrails/__init__.py create mode 100644 tests/cli/commands/ai_guardrails/scan/__init__.py create mode 100644 tests/cli/commands/ai_guardrails/scan/test_handlers.py create mode 100644 tests/cli/commands/ai_guardrails/scan/test_payload.py create mode 100644 tests/cli/commands/ai_guardrails/scan/test_policy.py create mode 100644 tests/cli/commands/ai_guardrails/scan/test_response_builders.py create mode 100644 tests/cli/commands/ai_guardrails/scan/test_utils.py create mode 100644 tests/cli/commands/ai_guardrails/test_command_utils.py diff --git a/cycode/cli/app.py b/cycode/cli/app.py index 3ef0b322..e838519e 100644 --- a/cycode/cli/app.py +++ b/cycode/cli/app.py @@ -9,7 +9,7 @@ from typer.completion import install_callback, show_callback from cycode import __version__ -from cycode.cli.apps import ai_remediation, auth, configure, ignore, report, report_import, scan, status +from cycode.cli.apps import ai_guardrails, ai_remediation, auth, configure, ignore, report, report_import, scan, status if sys.version_info >= (3, 10): from cycode.cli.apps import mcp @@ -45,6 +45,7 @@ add_completion=False, # we add it manually to control the rich help panel ) +app.add_typer(ai_guardrails.app) app.add_typer(ai_remediation.app) app.add_typer(auth.app) app.add_typer(configure.app) diff --git a/cycode/cli/apps/ai_guardrails/__init__.py b/cycode/cli/apps/ai_guardrails/__init__.py new file mode 100644 index 00000000..f8486ed4 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/__init__.py @@ -0,0 +1,19 @@ +import typer + +from cycode.cli.apps.ai_guardrails.install_command import install_command +from cycode.cli.apps.ai_guardrails.scan.scan_command import scan_command +from cycode.cli.apps.ai_guardrails.status_command import status_command +from cycode.cli.apps.ai_guardrails.uninstall_command import uninstall_command + +app = typer.Typer(name='ai-guardrails', no_args_is_help=True, hidden=True) + +app.command(hidden=True, name='install', short_help='Install AI guardrails hooks for supported IDEs.')(install_command) +app.command(hidden=True, name='uninstall', short_help='Remove AI guardrails hooks from supported IDEs.')( + uninstall_command +) +app.command(hidden=True, name='status', short_help='Show AI guardrails hook installation status.')(status_command) +app.command( + hidden=True, + name='scan', + short_help='Scan content from AI IDE hooks for secrets (reads JSON from stdin).', +)(scan_command) diff --git a/cycode/cli/apps/ai_guardrails/command_utils.py b/cycode/cli/apps/ai_guardrails/command_utils.py new file mode 100644 index 00000000..e010f0a2 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/command_utils.py @@ -0,0 +1,66 @@ +"""Common utilities for AI guardrails commands.""" + +import os +from pathlib import Path +from typing import Optional + +import typer +from rich.console import Console + +from cycode.cli.apps.ai_guardrails.consts import AIIDEType + +console = Console() + + +def validate_and_parse_ide(ide: str) -> AIIDEType: + """Validate IDE parameter and convert to AIIDEType enum. + + Args: + ide: IDE name string (e.g., 'cursor') + + Returns: + AIIDEType enum value + + Raises: + typer.Exit: If IDE is invalid + """ + try: + return AIIDEType(ide.lower()) + except ValueError: + valid_ides = ', '.join([ide_type.value for ide_type in AIIDEType]) + console.print( + f'[red]Error:[/] Invalid IDE "{ide}". Supported IDEs: {valid_ides}', + style='bold red', + ) + raise typer.Exit(1) from None + + +def validate_scope(scope: str, allowed_scopes: tuple[str, ...] = ('user', 'repo')) -> None: + """Validate scope parameter. + + Args: + scope: Scope string to validate + allowed_scopes: Tuple of allowed scope values + + Raises: + typer.Exit: If scope is invalid + """ + if scope not in allowed_scopes: + scopes_list = ', '.join(f'"{s}"' for s in allowed_scopes) + console.print(f'[red]Error:[/] Invalid scope. Use {scopes_list}.', style='bold red') + raise typer.Exit(1) + + +def resolve_repo_path(scope: str, repo_path: Optional[Path]) -> Optional[Path]: + """Resolve repository path, defaulting to current directory for repo scope. + + Args: + scope: The command scope ('user' or 'repo') + repo_path: Provided repo path or None + + Returns: + Resolved Path for repo scope, None for user scope + """ + if scope == 'repo' and repo_path is None: + return Path(os.getcwd()) + return repo_path diff --git a/cycode/cli/apps/ai_guardrails/consts.py b/cycode/cli/apps/ai_guardrails/consts.py new file mode 100644 index 00000000..21d89a3f --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/consts.py @@ -0,0 +1,78 @@ +"""Constants for AI guardrails hooks management. + +Currently supports: +- Cursor + +To add a new IDE (e.g., Claude Code): +1. Add new value to AIIDEType enum +2. Create _get__hooks_dir() function with platform-specific paths +3. Add entry to IDE_CONFIGS dict with IDE-specific hook event names +4. Unhide --ide option in commands (install, uninstall, status) +""" + +import platform +from enum import Enum +from pathlib import Path +from typing import NamedTuple + + +class AIIDEType(str, Enum): + """Supported AI IDE types.""" + + CURSOR = 'cursor' + + +class IDEConfig(NamedTuple): + """Configuration for an AI IDE.""" + + name: str + hooks_dir: Path + repo_hooks_subdir: str # Subdirectory in repo for hooks (e.g., '.cursor') + hooks_file_name: str + hook_events: list[str] # List of supported hook event names for this IDE + + +def _get_cursor_hooks_dir() -> Path: + """Get Cursor hooks directory based on platform.""" + if platform.system() == 'Darwin': + return Path.home() / '.cursor' + if platform.system() == 'Windows': + return Path.home() / 'AppData' / 'Roaming' / 'Cursor' + # Linux + return Path.home() / '.config' / 'Cursor' + + +# IDE-specific configurations +IDE_CONFIGS: dict[AIIDEType, IDEConfig] = { + AIIDEType.CURSOR: IDEConfig( + name='Cursor', + hooks_dir=_get_cursor_hooks_dir(), + repo_hooks_subdir='.cursor', + hooks_file_name='hooks.json', + hook_events=['beforeSubmitPrompt', 'beforeReadFile', 'beforeMCPExecution'], + ), +} + +# Default IDE +DEFAULT_IDE = AIIDEType.CURSOR + +# Command used in hooks +CYCODE_SCAN_PROMPT_COMMAND = 'cycode ai-guardrails scan' + + +def get_hooks_config(ide: AIIDEType) -> dict: + """Get the hooks configuration for a specific IDE. + + Args: + ide: The AI IDE type + + Returns: + Dict with hooks configuration for the specified IDE + """ + config = IDE_CONFIGS[ide] + hooks = {event: [{'command': CYCODE_SCAN_PROMPT_COMMAND}] for event in config.hook_events} + + return { + 'version': 1, + 'hooks': hooks, + } diff --git a/cycode/cli/apps/ai_guardrails/hooks_manager.py b/cycode/cli/apps/ai_guardrails/hooks_manager.py new file mode 100644 index 00000000..42f879f6 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/hooks_manager.py @@ -0,0 +1,200 @@ +""" +Hooks manager for AI guardrails. + +Handles installation, removal, and status checking of AI IDE hooks. +Supports multiple IDEs: Cursor, Claude Code (future). +""" + +import json +from pathlib import Path +from typing import Optional + +from cycode.cli.apps.ai_guardrails.consts import ( + CYCODE_SCAN_PROMPT_COMMAND, + DEFAULT_IDE, + IDE_CONFIGS, + AIIDEType, + get_hooks_config, +) +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails Hooks') + + +def get_hooks_path(scope: str, repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE) -> Path: + """Get the hooks.json path for the given scope and IDE. + + Args: + scope: 'user' for user-level hooks, 'repo' for repository-level hooks + repo_path: Repository path (required if scope is 'repo') + ide: The AI IDE type (default: Cursor) + """ + config = IDE_CONFIGS[ide] + if scope == 'repo' and repo_path: + return repo_path / config.repo_hooks_subdir / config.hooks_file_name + return config.hooks_dir / config.hooks_file_name + + +def load_hooks_file(hooks_path: Path) -> Optional[dict]: + """Load existing hooks.json file.""" + if not hooks_path.exists(): + return None + try: + content = hooks_path.read_text(encoding='utf-8') + return json.loads(content) + except Exception as e: + logger.debug('Failed to load hooks file', exc_info=e) + return None + + +def save_hooks_file(hooks_path: Path, hooks_config: dict) -> bool: + """Save hooks.json file.""" + try: + hooks_path.parent.mkdir(parents=True, exist_ok=True) + hooks_path.write_text(json.dumps(hooks_config, indent=2), encoding='utf-8') + return True + except Exception as e: + logger.error('Failed to save hooks file', exc_info=e) + return False + + +def is_cycode_hook_entry(entry: dict) -> bool: + """Check if a hook entry is from cycode-cli.""" + command = entry.get('command', '') + return CYCODE_SCAN_PROMPT_COMMAND in command + + +def install_hooks( + scope: str = 'user', repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE +) -> tuple[bool, str]: + """ + Install Cycode AI guardrails hooks. + + Args: + scope: 'user' for user-level hooks, 'repo' for repository-level hooks + repo_path: Repository path (required if scope is 'repo') + ide: The AI IDE type (default: Cursor) + + Returns: + Tuple of (success, message) + """ + hooks_path = get_hooks_path(scope, repo_path, ide) + + # Load existing hooks or create new + existing = load_hooks_file(hooks_path) or {'version': 1, 'hooks': {}} + existing.setdefault('version', 1) + existing.setdefault('hooks', {}) + + # Get IDE-specific hooks configuration + hooks_config = get_hooks_config(ide) + + # Add/update Cycode hooks + for event, entries in hooks_config['hooks'].items(): + existing['hooks'].setdefault(event, []) + + # Remove any existing Cycode entries for this event + existing['hooks'][event] = [e for e in existing['hooks'][event] if not is_cycode_hook_entry(e)] + + # Add new Cycode entries + for entry in entries: + existing['hooks'][event].append(entry) + + # Save + if save_hooks_file(hooks_path, existing): + return True, f'AI guardrails hooks installed: {hooks_path}' + return False, f'Failed to install hooks to {hooks_path}' + + +def uninstall_hooks( + scope: str = 'user', repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE +) -> tuple[bool, str]: + """ + Remove Cycode AI guardrails hooks. + + Args: + scope: 'user' for user-level hooks, 'repo' for repository-level hooks + repo_path: Repository path (required if scope is 'repo') + ide: The AI IDE type (default: Cursor) + + Returns: + Tuple of (success, message) + """ + hooks_path = get_hooks_path(scope, repo_path, ide) + + existing = load_hooks_file(hooks_path) + if existing is None: + return True, f'No hooks file found at {hooks_path}' + + # Remove Cycode entries from all events + modified = False + for event in list(existing.get('hooks', {}).keys()): + original_count = len(existing['hooks'][event]) + existing['hooks'][event] = [e for e in existing['hooks'][event] if not is_cycode_hook_entry(e)] + if len(existing['hooks'][event]) != original_count: + modified = True + # Remove empty event lists + if not existing['hooks'][event]: + del existing['hooks'][event] + + if not modified: + return True, 'No Cycode hooks found to remove' + + # Save or delete if empty + if not existing.get('hooks'): + try: + hooks_path.unlink() + return True, f'Removed hooks file: {hooks_path}' + except Exception as e: + logger.debug('Failed to delete hooks file', exc_info=e) + return False, f'Failed to remove hooks file: {hooks_path}' + + if save_hooks_file(hooks_path, existing): + return True, f'Cycode hooks removed from: {hooks_path}' + return False, f'Failed to update hooks file: {hooks_path}' + + +def get_hooks_status(scope: str = 'user', repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE) -> dict: + """ + Get the status of AI guardrails hooks. + + Args: + scope: 'user' for user-level hooks, 'repo' for repository-level hooks + repo_path: Repository path (required if scope is 'repo') + ide: The AI IDE type (default: Cursor) + + Returns: + Dict with status information + """ + hooks_path = get_hooks_path(scope, repo_path, ide) + + status = { + 'scope': scope, + 'ide': ide.value, + 'ide_name': IDE_CONFIGS[ide].name, + 'hooks_path': str(hooks_path), + 'file_exists': hooks_path.exists(), + 'cycode_installed': False, + 'hooks': {}, + } + + existing = load_hooks_file(hooks_path) + if existing is None: + return status + + # Check each hook event for this IDE + ide_config = IDE_CONFIGS[ide] + has_cycode_hooks = False + for event in ide_config.hook_events: + entries = existing.get('hooks', {}).get(event, []) + cycode_entries = [e for e in entries if is_cycode_hook_entry(e)] + if cycode_entries: + has_cycode_hooks = True + status['hooks'][event] = { + 'total_entries': len(entries), + 'cycode_entries': len(cycode_entries), + 'enabled': len(cycode_entries) > 0, + } + + status['cycode_installed'] = has_cycode_hooks + + return status diff --git a/cycode/cli/apps/ai_guardrails/install_command.py b/cycode/cli/apps/ai_guardrails/install_command.py new file mode 100644 index 00000000..6186752d --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/install_command.py @@ -0,0 +1,78 @@ +"""Install command for AI guardrails hooks.""" + +from pathlib import Path +from typing import Annotated, Optional + +import typer + +from cycode.cli.apps.ai_guardrails.command_utils import ( + console, + resolve_repo_path, + validate_and_parse_ide, + validate_scope, +) +from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS +from cycode.cli.apps.ai_guardrails.hooks_manager import install_hooks +from cycode.cli.utils.sentry import add_breadcrumb + + +def install_command( + ctx: typer.Context, + scope: Annotated[ + str, + typer.Option( + '--scope', + '-s', + help='Installation scope: "user" for all projects, "repo" for current repository only.', + ), + ] = 'user', + ide: Annotated[ + str, + typer.Option( + '--ide', + help='IDE to install hooks for (e.g., "cursor"). Defaults to cursor.', + ), + ] = 'cursor', + repo_path: Annotated[ + Optional[Path], + typer.Option( + '--repo-path', + help='Repository path for repo-scoped installation (defaults to current directory).', + exists=True, + file_okay=False, + dir_okay=True, + resolve_path=True, + ), + ] = None, +) -> None: + """Install AI guardrails hooks for supported IDEs. + + This command configures the specified IDE to use Cycode for scanning prompts, file reads, + and MCP tool calls for secrets before they are sent to AI models. + + Examples: + cycode ai-guardrails install # Install for all projects (user scope) + cycode ai-guardrails install --scope repo # Install for current repo only + cycode ai-guardrails install --ide cursor # Install for Cursor IDE + cycode ai-guardrails install --scope repo --repo-path /path/to/repo + """ + add_breadcrumb('ai-guardrails-install') + + # Validate inputs + validate_scope(scope) + repo_path = resolve_repo_path(scope, repo_path) + ide_type = validate_and_parse_ide(ide) + ide_name = IDE_CONFIGS[ide_type].name + success, message = install_hooks(scope, repo_path, ide=ide_type) + + if success: + console.print(f'[green]✓[/] {message}') + console.print() + console.print('[bold]Next steps:[/]') + console.print(f'1. Restart {ide_name} to activate the hooks') + console.print('2. (Optional) Customize policy in ~/.cycode/ai-guardrails.yaml') + console.print() + console.print('[dim]The hooks will scan prompts, file reads, and MCP tool calls for secrets.[/]') + else: + console.print(f'[red]✗[/] {message}', style='bold red') + raise typer.Exit(1) diff --git a/cycode/cli/apps/ai_guardrails/scan/__init__.py b/cycode/cli/apps/ai_guardrails/scan/__init__.py new file mode 100644 index 00000000..47349e78 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/__init__.py @@ -0,0 +1 @@ +# Prompt scan command for AI guardrails (hooks) diff --git a/cycode/cli/apps/ai_guardrails/scan/consts.py b/cycode/cli/apps/ai_guardrails/scan/consts.py new file mode 100644 index 00000000..007892a8 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/consts.py @@ -0,0 +1,48 @@ +""" +Constants and default configuration for AI guardrails. + +These defaults can be overridden by: +1. User-level config: ~/.cycode/ai-guardrails.yaml +2. Repo-level config: /.cycode/ai-guardrails.yaml +""" + +# Policy file name +POLICY_FILE_NAME = 'ai-guardrails.yaml' + +# Default policy configuration +DEFAULT_POLICY = { + 'version': 1, + 'mode': 'block', # block | warn + 'fail_open': True, # allow if scan fails/timeouts + 'secrets': { + 'scan_type': 'secret', + 'timeout_ms': 30000, + 'max_bytes': 200000, + }, + 'prompt': { + 'enabled': True, + 'action': 'block', + }, + 'file_read': { + 'enabled': True, + 'action': 'block', + 'deny_globs': [ + '.env', + '.env.*', + '*.pem', + '*.p12', + '*.key', + '.aws/**', + '.ssh/**', + '*kubeconfig*', + '.npmrc', + '.netrc', + ], + 'scan_content': True, + }, + 'mcp': { + 'enabled': True, + 'action': 'block', + 'scan_arguments': True, + }, +} diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py new file mode 100644 index 00000000..95e9d606 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -0,0 +1,341 @@ +""" +Hook handlers for AI IDE events. + +Each handler receives a unified payload from an IDE, applies policy rules, +and returns a response that either allows or blocks the action. +""" + +import json +import os +from multiprocessing.pool import ThreadPool +from multiprocessing.pool import TimeoutError as PoolTimeoutError +from typing import Callable, Optional + +import typer + +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value +from cycode.cli.apps.ai_guardrails.scan.response_builders import get_response_builder +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType, AIHookOutcome, BlockReason +from cycode.cli.apps.ai_guardrails.scan.utils import is_denied_path, truncate_utf8 +from cycode.cli.apps.scan.code_scanner import _get_scan_documents_thread_func +from cycode.cli.apps.scan.scan_parameters import get_scan_parameters +from cycode.cli.cli_types import ScanTypeOption, SeverityOption +from cycode.cli.models import Document +from cycode.cli.utils.progress_bar import DummyProgressBar, ScanProgressBarSection +from cycode.cli.utils.scan_utils import build_violation_summary +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails') + + +def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> dict: + """ + Handle beforeSubmitPrompt hook. + + Scans prompt text for secrets before it's sent to the AI model. + Returns {"continue": False} to block, {"continue": True} to allow. + """ + ai_client = ctx.obj['ai_security_client'] + ide = payload.ide_provider + response_builder = get_response_builder(ide) + + prompt_config = get_policy_value(policy, 'prompt', default={}) + ai_client.create_conversation(payload) + if not get_policy_value(prompt_config, 'enabled', default=True): + ai_client.create_event(payload, AiHookEventType.PROMPT, AIHookOutcome.ALLOWED) + return response_builder.allow_prompt() + + mode = get_policy_value(policy, 'mode', default='block') + prompt = payload.prompt or '' + max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000) + timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000) + clipped = truncate_utf8(prompt, max_bytes) + + scan_id = None + block_reason = None + outcome = AIHookOutcome.ALLOWED + + try: + violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms) + + if ( + violation_summary + and get_policy_value(prompt_config, 'action', default='block') == 'block' + and mode == 'block' + ): + outcome = AIHookOutcome.BLOCKED + block_reason = BlockReason.SECRETS_IN_PROMPT + user_message = f'{violation_summary}. Remove secrets before sending.' + response = response_builder.deny_prompt(user_message) + else: + if violation_summary: + outcome = AIHookOutcome.WARNED + response = response_builder.allow_prompt() + return response + except Exception as e: + outcome = ( + AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED + ) + block_reason = BlockReason.SCAN_FAILURE if outcome == AIHookOutcome.BLOCKED else None + raise e + finally: + ai_client.create_event( + payload, + AiHookEventType.PROMPT, + outcome, + scan_id=scan_id, + block_reason=block_reason, + ) + + +def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> dict: + """ + Handle beforeReadFile hook. + + Blocks sensitive files (via deny_globs) and scans file content for secrets. + Returns {"permission": "deny"} to block, {"permission": "allow"} to allow. + """ + ai_client = ctx.obj['ai_security_client'] + ide = payload.ide_provider + response_builder = get_response_builder(ide) + + file_read_config = get_policy_value(policy, 'file_read', default={}) + ai_client.create_conversation(payload) + if not get_policy_value(file_read_config, 'enabled', default=True): + ai_client.create_event(payload, AiHookEventType.FILE_READ, AIHookOutcome.ALLOWED) + return response_builder.allow_permission() + + mode = get_policy_value(policy, 'mode', default='block') + file_path = payload.file_path or '' + action = get_policy_value(file_read_config, 'action', default='block') + + scan_id = None + block_reason = None + outcome = AIHookOutcome.ALLOWED + + try: + # Check path-based denylist first + if is_denied_path(file_path, policy) and action == 'block': + outcome = AIHookOutcome.BLOCKED + block_reason = BlockReason.SENSITIVE_PATH + user_message = f'Cycode blocked sending {file_path} to the AI (sensitive path policy).' + return response_builder.deny_permission( + user_message, + 'This file path is classified as sensitive; do not read/send it to the model.', + ) + + # Scan file content if enabled + if get_policy_value(file_read_config, 'scan_content', default=True): + violation_summary, scan_id = _scan_path_for_secrets(ctx, file_path, policy) + if violation_summary and action == 'block' and mode == 'block': + outcome = AIHookOutcome.BLOCKED + block_reason = BlockReason.SECRETS_IN_FILE + user_message = f'Cycode blocked reading {file_path}. {violation_summary}' + return response_builder.deny_permission( + user_message, + 'Secrets detected; do not send this file to the model.', + ) + if violation_summary: + outcome = AIHookOutcome.WARNED + return response_builder.allow_permission() + + return response_builder.allow_permission() + except Exception as e: + outcome = ( + AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED + ) + block_reason = BlockReason.SCAN_FAILURE if outcome == AIHookOutcome.BLOCKED else None + raise e + finally: + ai_client.create_event( + payload, + AiHookEventType.FILE_READ, + outcome, + scan_id=scan_id, + block_reason=block_reason, + ) + + +def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> dict: + """ + Handle beforeMCPExecution hook. + + Scans tool arguments for secrets before MCP tool execution. + Returns {"permission": "deny"} to block, {"permission": "ask"} to warn, + {"permission": "allow"} to allow. + """ + ai_client = ctx.obj['ai_security_client'] + ide = payload.ide_provider + response_builder = get_response_builder(ide) + + mcp_config = get_policy_value(policy, 'mcp', default={}) + ai_client.create_conversation(payload) + if not get_policy_value(mcp_config, 'enabled', default=True): + ai_client.create_event(payload, AiHookEventType.MCP_EXECUTION, AIHookOutcome.ALLOWED) + return response_builder.allow_permission() + + mode = get_policy_value(policy, 'mode', default='block') + tool = payload.mcp_tool_name or 'unknown' + args = payload.mcp_arguments or {} + args_text = args if isinstance(args, str) else json.dumps(args) + max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000) + timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000) + clipped = truncate_utf8(args_text, max_bytes) + action = get_policy_value(mcp_config, 'action', default='block') + + scan_id = None + block_reason = None + outcome = AIHookOutcome.ALLOWED + + try: + if get_policy_value(mcp_config, 'scan_arguments', default=True): + violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms) + if violation_summary: + if mode == 'block' and action == 'block': + outcome = AIHookOutcome.BLOCKED + block_reason = BlockReason.SECRETS_IN_MCP_ARGS + user_message = f'Cycode blocked MCP tool call "{tool}". {violation_summary}' + return response_builder.deny_permission( + user_message, + 'Do not pass secrets to tools. Use secret references (name/id) instead.', + ) + outcome = AIHookOutcome.WARNED + return response_builder.ask_permission( + f'{violation_summary} in MCP tool call "{tool}". Allow execution?', + 'Possible secrets detected in tool arguments; proceed with caution.', + ) + + return response_builder.allow_permission() + except Exception as e: + outcome = ( + AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED + ) + block_reason = BlockReason.SCAN_FAILURE if outcome == AIHookOutcome.BLOCKED else None + raise e + finally: + ai_client.create_event( + payload, + AiHookEventType.MCP_EXECUTION, + outcome, + scan_id=scan_id, + block_reason=block_reason, + ) + + +def get_handler_for_event(event_type: str) -> Optional[Callable[[typer.Context, AIHookPayload, dict], dict]]: + """Get the appropriate handler function for a canonical event type. + + Args: + event_type: Canonical event type string (from AiHookEventType enum) + + Returns: + Handler function or None if event type is not recognized + """ + handlers = { + AiHookEventType.PROMPT.value: handle_before_submit_prompt, + AiHookEventType.FILE_READ.value: handle_before_read_file, + AiHookEventType.MCP_EXECUTION.value: handle_before_mcp_execution, + } + return handlers.get(event_type) + + +def _setup_scan_context(ctx: typer.Context) -> typer.Context: + """Set up minimal context for scan_documents without progress bars or printing.""" + + # Set up minimal required context + ctx.obj['progress_bar'] = DummyProgressBar([ScanProgressBarSection]) + ctx.obj['sync'] = True # Synchronous scan + ctx.obj['scan_type'] = ScanTypeOption.SECRET # AI guardrails always scans for secrets + ctx.obj['severity_threshold'] = SeverityOption.INFO # Report all severities + + # Set command name for scan logic + ctx.info_name = 'ai_guardrails' + + return ctx + + +def _perform_scan( + ctx: typer.Context, documents: list[Document], scan_parameters: dict, timeout_seconds: float +) -> tuple[Optional[str], Optional[str]]: + """ + Perform a scan on documents and extract results. + + Returns tuple of (violation_summary, scan_id) if secrets found, (None, scan_id) if clean. + Raises exception if scan fails or times out (triggers fail_open policy). + """ + if not documents: + return None, None + + # Get the thread function for scanning + scan_batch_thread_func = _get_scan_documents_thread_func( + ctx, is_git_diff=False, is_commit_range=False, scan_parameters=scan_parameters + ) + + # Use ThreadPool.apply_async with timeout to abort if scan takes too long + # This uses the same ThreadPool mechanism as run_parallel_batched_scan but with timeout support + with ThreadPool(processes=1) as pool: + result = pool.apply_async(scan_batch_thread_func, (documents,)) + try: + scan_id, error, local_scan_result = result.get(timeout=timeout_seconds) + except PoolTimeoutError: + logger.debug('Scan timed out after %s seconds', timeout_seconds) + raise RuntimeError(f'Scan timed out after {timeout_seconds} seconds') from None + + # Check if scan failed - raise exception to trigger fail_open policy + if error: + raise RuntimeError(error.message) + + if not local_scan_result: + return None, None + + scan_id = local_scan_result.scan_id + + # Check if there are any detections + if local_scan_result.detections_count > 0: + violation_summary = build_violation_summary([local_scan_result]) + return violation_summary, scan_id + + return None, scan_id + + +def _scan_text_for_secrets(ctx: typer.Context, text: str, timeout_ms: int) -> tuple[Optional[str], Optional[str]]: + """ + Scan text content for secrets using Cycode CLI. + + Returns tuple of (violation_summary, scan_id) if secrets found, (None, scan_id) if clean. + Raises exception on error or timeout. + """ + if not text: + return None, None + + document = Document(path='prompt-content.txt', content=text, is_git_diff_format=False) + scan_ctx = _setup_scan_context(ctx) + timeout_seconds = timeout_ms / 1000.0 + return _perform_scan(scan_ctx, [document], get_scan_parameters(scan_ctx, None), timeout_seconds) + + +def _scan_path_for_secrets(ctx: typer.Context, file_path: str, policy: dict) -> tuple[Optional[str], Optional[str]]: + """ + Scan a file path for secrets. + + Returns tuple of (violation_summary, scan_id) if secrets found, (None, scan_id) if clean. + Raises exception on error or timeout. + """ + if not file_path or not os.path.exists(file_path): + return None, None + + with open(file_path, encoding='utf-8', errors='replace') as f: + content = f.read() + + # Truncate content based on policy max_bytes + max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000) + content = truncate_utf8(content, max_bytes) + + # Get timeout from policy + timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000) + timeout_seconds = timeout_ms / 1000.0 + + document = Document(path=os.path.basename(file_path), content=content, is_git_diff_format=False) + scan_ctx = _setup_scan_context(ctx) + return _perform_scan(scan_ctx, [document], get_scan_parameters(scan_ctx, (file_path,)), timeout_seconds) diff --git a/cycode/cli/apps/ai_guardrails/scan/payload.py b/cycode/cli/apps/ai_guardrails/scan/payload.py new file mode 100644 index 00000000..83787348 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/payload.py @@ -0,0 +1,72 @@ +"""Unified payload object for AI hook events from different tools.""" + +from dataclasses import dataclass +from typing import Optional + +from cycode.cli.apps.ai_guardrails.scan.types import CURSOR_EVENT_MAPPING + + +@dataclass +class AIHookPayload: + """Unified payload object that normalizes field names from different AI tools.""" + + # Event identification + event_name: str # Canonical event type (e.g., 'prompt', 'file_read', 'mcp_execution') + conversation_id: Optional[str] = None + generation_id: Optional[str] = None + + # User and IDE information + ide_user_email: Optional[str] = None + model: Optional[str] = None + ide_provider: str = None # e.g., 'cursor', 'claude-code' + ide_version: Optional[str] = None + + # Event-specific data + prompt: Optional[str] = None # For prompt events + file_path: Optional[str] = None # For file_read events + mcp_server_name: Optional[str] = None # For mcp_execution events + mcp_tool_name: Optional[str] = None # For mcp_execution events + mcp_arguments: Optional[dict] = None # For mcp_execution events + + @classmethod + def from_cursor_payload(cls, payload: dict) -> 'AIHookPayload': + """Create AIHookPayload from Cursor IDE payload. + + Maps Cursor-specific event names to canonical event types. + """ + cursor_event_name = payload.get('hook_event_name', '') + # Map Cursor event name to canonical type, fallback to original if not found + canonical_event = CURSOR_EVENT_MAPPING.get(cursor_event_name, cursor_event_name) + + return cls( + event_name=canonical_event, + conversation_id=payload.get('conversation_id'), + generation_id=payload.get('generation_id'), + ide_user_email=payload.get('user_email'), + model=payload.get('model'), + ide_provider='cursor', + ide_version=payload.get('cursor_version'), + prompt=payload.get('prompt', ''), + file_path=payload.get('file_path') or payload.get('path'), + mcp_server_name=payload.get('command'), # MCP server name + mcp_tool_name=payload.get('tool_name') or payload.get('tool'), + mcp_arguments=payload.get('arguments') or payload.get('tool_input') or payload.get('input'), + ) + + @classmethod + def from_payload(cls, payload: dict, tool: str = 'cursor') -> 'AIHookPayload': + """Create AIHookPayload from any tool's payload. + + Args: + payload: The raw payload from the IDE + tool: The IDE/tool name (e.g., 'cursor') + + Returns: + AIHookPayload instance + + Raises: + ValueError: If the tool is not supported + """ + if tool == 'cursor': + return cls.from_cursor_payload(payload) + raise ValueError(f'Unsupported IDE/tool: {tool}.') diff --git a/cycode/cli/apps/ai_guardrails/scan/policy.py b/cycode/cli/apps/ai_guardrails/scan/policy.py new file mode 100644 index 00000000..f40d77c0 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/policy.py @@ -0,0 +1,85 @@ +""" +Policy loading and configuration management for AI guardrails. + +Policies are loaded and merged in order (later overrides earlier): +1. Built-in defaults (consts.DEFAULT_POLICY) +2. User-level config (~/.cycode/ai-guardrails.yaml) +3. Repo-level config (/.cycode/ai-guardrails.yaml) +""" + +import json +from pathlib import Path +from typing import Any, Optional + +import yaml + +from cycode.cli.apps.ai_guardrails.scan.consts import DEFAULT_POLICY, POLICY_FILE_NAME + + +def deep_merge(base: dict, override: dict) -> dict: + """Deep merge two dictionaries, with override taking precedence.""" + result = base.copy() + for key, value in override.items(): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + result[key] = deep_merge(result[key], value) + else: + result[key] = value + return result + + +def load_yaml_file(path: Path) -> Optional[dict]: + """Load a YAML or JSON config file.""" + if not path.exists(): + return None + try: + content = path.read_text(encoding='utf-8') + if path.suffix in ('.yaml', '.yml'): + return yaml.safe_load(content) + return json.loads(content) + except Exception: + return None + + +def load_defaults() -> dict: + """Load built-in defaults.""" + return DEFAULT_POLICY.copy() + + +def get_policy_value(policy: dict, *keys: str, default: Any = None) -> Any: + """Get a nested value from the policy dict.""" + current = policy + for key in keys: + if not isinstance(current, dict): + return default + current = current.get(key) + if current is None: + return default + return current + + +def load_policy(workspace_root: Optional[str] = None) -> dict: + """ + Load policy by merging configs in order of precedence. + + Merge order: defaults <- user config <- repo config + + Args: + workspace_root: Workspace root path for repo-level config lookup. + """ + # Start with defaults + policy = load_defaults() + + # Merge user-level config (if exists) + user_policy_path = Path.home() / '.cycode' / POLICY_FILE_NAME + user_config = load_yaml_file(user_policy_path) + if user_config: + policy = deep_merge(policy, user_config) + + # Merge repo-level config (if exists) - highest precedence + if workspace_root: + repo_policy_path = Path(workspace_root) / '.cycode' / POLICY_FILE_NAME + repo_config = load_yaml_file(repo_policy_path) + if repo_config: + policy = deep_merge(policy, repo_config) + + return policy diff --git a/cycode/cli/apps/ai_guardrails/scan/response_builders.py b/cycode/cli/apps/ai_guardrails/scan/response_builders.py new file mode 100644 index 00000000..867965c3 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/response_builders.py @@ -0,0 +1,86 @@ +""" +Response builders for different AI IDE hooks. + +Each IDE has its own response format for hooks. This module provides +an abstract interface and concrete implementations for each supported IDE. +""" + +from abc import ABC, abstractmethod + + +class IDEResponseBuilder(ABC): + """Abstract base class for IDE-specific response builders.""" + + @abstractmethod + def allow_permission(self) -> dict: + """Build response to allow file read or MCP execution.""" + + @abstractmethod + def deny_permission(self, user_message: str, agent_message: str) -> dict: + """Build response to deny file read or MCP execution.""" + + @abstractmethod + def ask_permission(self, user_message: str, agent_message: str) -> dict: + """Build response to ask user for permission (warn mode).""" + + @abstractmethod + def allow_prompt(self) -> dict: + """Build response to allow prompt submission.""" + + @abstractmethod + def deny_prompt(self, user_message: str) -> dict: + """Build response to deny prompt submission.""" + + +class CursorResponseBuilder(IDEResponseBuilder): + """Response builder for Cursor IDE hooks. + + Cursor hook response formats: + - beforeSubmitPrompt: {"continue": bool, "user_message": str} + - beforeReadFile: {"permission": str, "user_message": str, "agent_message": str} + - beforeMCPExecution: {"permission": str, "user_message": str, "agent_message": str} + """ + + def allow_permission(self) -> dict: + """Allow file read or MCP execution.""" + return {'permission': 'allow'} + + def deny_permission(self, user_message: str, agent_message: str) -> dict: + """Deny file read or MCP execution.""" + return {'permission': 'deny', 'user_message': user_message, 'agent_message': agent_message} + + def ask_permission(self, user_message: str, agent_message: str) -> dict: + """Ask user for permission (warn mode).""" + return {'permission': 'ask', 'user_message': user_message, 'agent_message': agent_message} + + def allow_prompt(self) -> dict: + """Allow prompt submission.""" + return {'continue': True} + + def deny_prompt(self, user_message: str) -> dict: + """Deny prompt submission.""" + return {'continue': False, 'user_message': user_message} + + +# Registry of response builders by IDE name +_RESPONSE_BUILDERS: dict[str, IDEResponseBuilder] = { + 'cursor': CursorResponseBuilder(), +} + + +def get_response_builder(ide: str = 'cursor') -> IDEResponseBuilder: + """Get the response builder for a specific IDE. + + Args: + ide: The IDE name (e.g., 'cursor', 'claude-code') + + Returns: + IDEResponseBuilder instance for the specified IDE + + Raises: + ValueError: If the IDE is not supported + """ + builder = _RESPONSE_BUILDERS.get(ide.lower()) + if not builder: + raise ValueError(f'Unsupported IDE: {ide}. Supported IDEs: {list(_RESPONSE_BUILDERS.keys())}') + return builder diff --git a/cycode/cli/apps/ai_guardrails/scan/scan_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py new file mode 100644 index 00000000..e08bb4de --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -0,0 +1,134 @@ +""" +Scan command for AI guardrails. + +This command handles AI IDE hooks by reading JSON from stdin and outputting +a JSON response to stdout. It scans prompts, file reads, and MCP tool calls +for secrets before they are sent to AI models. + +Supports multiple IDEs with different hook event types. The specific hook events +supported depend on the IDE being used (e.g., Cursor supports beforeSubmitPrompt, +beforeReadFile, beforeMCPExecution). +""" + +import sys +from typing import Annotated + +import click +import typer + +from cycode.cli.apps.ai_guardrails.scan.handlers import get_handler_for_event +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.policy import load_policy +from cycode.cli.apps.ai_guardrails.scan.response_builders import get_response_builder +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType +from cycode.cli.apps.ai_guardrails.scan.utils import output_json, safe_json_parse +from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError +from cycode.cli.utils.get_api_client import get_ai_security_manager_client, get_scan_cycode_client +from cycode.cli.utils.sentry import add_breadcrumb +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails') + + +def _get_auth_error_message(error: Exception) -> str: + """Get user-friendly message for authentication errors.""" + if isinstance(error, click.ClickException): + # Missing credentials + return f'{error.message} Please run `cycode configure` to set up your credentials.' + + if isinstance(error, HttpUnauthorizedError): + # Invalid/expired credentials + return ( + 'Unable to authenticate to Cycode. Your credentials are invalid or have expired. ' + 'Please run `cycode configure` to update your credentials.' + ) + + # Fallback + return 'Authentication failed. Please run `cycode configure` to set up your credentials.' + + +def _initialize_clients(ctx: typer.Context) -> None: + """Initialize API clients. + + May raise click.ClickException if credentials are missing, + or HttpUnauthorizedError if credentials are invalid. + """ + scan_client = get_scan_cycode_client(ctx) + ctx.obj['client'] = scan_client + + ai_security_client = get_ai_security_manager_client(ctx) + ctx.obj['ai_security_client'] = ai_security_client + + +def scan_command( + ctx: typer.Context, + ide: Annotated[ + str, + typer.Option( + '--ide', + help='IDE that sent the payload (e.g., "cursor"). Defaults to cursor.', + hidden=True, + ), + ] = 'cursor', +) -> None: + """Scan content from AI IDE hooks for secrets. + + This command reads a JSON payload from stdin containing hook event data + and outputs a JSON response to stdout indicating whether to allow or block the action. + + The hook event type is determined from the event field in the payload (field name + varies by IDE). Each IDE may support different hook events for scanning prompts, + file access, and tool executions. + + Example usage (from IDE hooks configuration): + { "command": "cycode ai-guardrails scan" } + """ + add_breadcrumb('ai-guardrails-scan') + + stdin_data = sys.stdin.read().strip() + payload = safe_json_parse(stdin_data) + + tool = ide.lower() + response_builder = get_response_builder(tool) + + if not payload: + logger.debug('Empty or invalid JSON payload received') + output_json(response_builder.allow_prompt()) + return + + unified_payload = AIHookPayload.from_payload(payload, tool=tool) + event_name = unified_payload.event_name + logger.debug('Processing AI guardrails hook', extra={'event_name': event_name, 'tool': tool}) + + workspace_roots = payload.get('workspace_roots', ['.']) + policy = load_policy(workspace_roots[0]) + + try: + _initialize_clients(ctx) + + handler = get_handler_for_event(event_name) + if handler is None: + logger.debug('Unknown hook event, allowing by default', extra={'event_name': event_name}) + output_json(response_builder.allow_prompt()) + return + + response = handler(ctx, unified_payload, policy) + logger.debug('Hook handler completed', extra={'event_name': event_name, 'response': response}) + output_json(response) + + except (click.ClickException, HttpUnauthorizedError) as e: + error_message = _get_auth_error_message(e) + if event_name == AiHookEventType.PROMPT: + output_json(response_builder.deny_prompt(error_message)) + return + output_json(response_builder.deny_permission(error_message, 'Authentication required')) + + except Exception as e: + logger.error('Hook handler failed', exc_info=e) + if policy.get('fail_open', True): + output_json(response_builder.allow_prompt()) + return + if event_name == AiHookEventType.PROMPT: + output_json(response_builder.deny_prompt('Cycode guardrails error - blocking due to fail-closed policy')) + return + output_json(response_builder.deny_permission('Cycode guardrails error', 'Blocking due to fail-closed policy')) diff --git a/cycode/cli/apps/ai_guardrails/scan/types.py b/cycode/cli/apps/ai_guardrails/scan/types.py new file mode 100644 index 00000000..095ca61b --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/types.py @@ -0,0 +1,54 @@ +"""Type definitions for AI guardrails.""" + +import sys + +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + from enum import Enum + + class StrEnum(str, Enum): + def __str__(self) -> str: + return self.value + + +class AiHookEventType(StrEnum): + """Canonical event types for AI guardrails. + + These are IDE-agnostic event types. Each IDE's specific event names + are mapped to these canonical types using the mapping dictionaries below. + """ + + PROMPT = 'Prompt' + FILE_READ = 'FileRead' + MCP_EXECUTION = 'McpExecution' + + +# IDE-specific event name mappings to canonical types +CURSOR_EVENT_MAPPING = { + 'beforeSubmitPrompt': AiHookEventType.PROMPT, + 'beforeReadFile': AiHookEventType.FILE_READ, + 'beforeMCPExecution': AiHookEventType.MCP_EXECUTION, +} + + +class AIHookOutcome(StrEnum): + """Outcome of an AI hook event evaluation.""" + + ALLOWED = 'allowed' + BLOCKED = 'blocked' + WARNED = 'warned' + + +class BlockReason(StrEnum): + """Reason why an AI hook event was blocked. + + These are categorical reasons sent to the backend for tracking/analytics, + separate from the detailed user-facing messages. + """ + + SECRETS_IN_PROMPT = 'secrets_in_prompt' + SECRETS_IN_FILE = 'secrets_in_file' + SECRETS_IN_MCP_ARGS = 'secrets_in_mcp_args' + SENSITIVE_PATH = 'sensitive_path' + SCAN_FAILURE = 'scan_failure' diff --git a/cycode/cli/apps/ai_guardrails/scan/utils.py b/cycode/cli/apps/ai_guardrails/scan/utils.py new file mode 100644 index 00000000..e14c1c02 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/utils.py @@ -0,0 +1,72 @@ +""" +Utility functions for AI guardrails. + +Includes JSON parsing, path matching, and text handling utilities. +""" + +import json +import os +from pathlib import Path + +from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value + + +def safe_json_parse(s: str) -> dict: + """Parse JSON string, returning empty dict on failure.""" + try: + return json.loads(s) if s else {} + except (json.JSONDecodeError, TypeError): + return {} + + +def truncate_utf8(text: str, max_bytes: int) -> str: + """Truncate text to max bytes while preserving valid UTF-8.""" + if not text: + return '' + encoded = text.encode('utf-8') + if len(encoded) <= max_bytes: + return text + return encoded[:max_bytes].decode('utf-8', errors='ignore') + + +def normalize_path(file_path: str) -> str: + """Normalize path to prevent traversal attacks.""" + if not file_path: + return '' + normalized = os.path.normpath(file_path) + # Reject paths that attempt to escape outside bounds + if normalized.startswith('..'): + return '' + return normalized + + +def matches_glob(file_path: str, pattern: str) -> bool: + """Check if file path matches a glob pattern. + + Case-insensitive matching for cross-platform compatibility. + """ + normalized = normalize_path(file_path) + if not normalized or not pattern: + return False + + path = Path(normalized) + # Try case-sensitive first + if path.match(pattern): + return True + + # Then try case-insensitive by lowercasing both path and pattern + path_lower = Path(normalized.lower()) + return path_lower.match(pattern.lower()) + + +def is_denied_path(file_path: str, policy: dict) -> bool: + """Check if file path is in the denylist.""" + if not file_path: + return False + globs = get_policy_value(policy, 'file_read', 'deny_globs', default=[]) + return any(matches_glob(file_path, g) for g in globs) + + +def output_json(obj: dict) -> None: + """Write JSON response to stdout (for IDE to read).""" + print(json.dumps(obj), end='') # noqa: T201 diff --git a/cycode/cli/apps/ai_guardrails/status_command.py b/cycode/cli/apps/ai_guardrails/status_command.py new file mode 100644 index 00000000..0a9801b5 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/status_command.py @@ -0,0 +1,92 @@ +"""Status command for AI guardrails hooks.""" + +import os +from pathlib import Path +from typing import Annotated, Optional + +import typer +from rich.table import Table + +from cycode.cli.apps.ai_guardrails.command_utils import console, validate_and_parse_ide, validate_scope +from cycode.cli.apps.ai_guardrails.hooks_manager import get_hooks_status +from cycode.cli.utils.sentry import add_breadcrumb + + +def status_command( + ctx: typer.Context, + scope: Annotated[ + str, + typer.Option( + '--scope', + '-s', + help='Check scope: "user", "repo", or "all" for both.', + ), + ] = 'all', + ide: Annotated[ + str, + typer.Option( + '--ide', + help='IDE to check status for (e.g., "cursor"). Defaults to cursor.', + ), + ] = 'cursor', + repo_path: Annotated[ + Optional[Path], + typer.Option( + '--repo-path', + help='Repository path for repo-scoped status (defaults to current directory).', + exists=True, + file_okay=False, + dir_okay=True, + resolve_path=True, + ), + ] = None, +) -> None: + """Show AI guardrails hook installation status. + + Displays the current status of Cycode AI guardrails hooks for the specified IDE. + + Examples: + cycode ai-guardrails status # Show both user and repo status + cycode ai-guardrails status --scope user # Show only user-level status + cycode ai-guardrails status --scope repo # Show only repo-level status + cycode ai-guardrails status --ide cursor # Check status for Cursor IDE + """ + add_breadcrumb('ai-guardrails-status') + + # Validate inputs (status allows 'all' scope) + validate_scope(scope, allowed_scopes=('user', 'repo', 'all')) + if repo_path is None: + repo_path = Path(os.getcwd()) + ide_type = validate_and_parse_ide(ide) + + scopes_to_check = ['user', 'repo'] if scope == 'all' else [scope] + + for check_scope in scopes_to_check: + status = get_hooks_status(check_scope, repo_path if check_scope == 'repo' else None, ide=ide_type) + + console.print() + console.print(f'[bold]{check_scope.upper()} SCOPE[/]') + console.print(f'Path: {status["hooks_path"]}') + + if not status['file_exists']: + console.print('[dim]No hooks.json file found[/]') + continue + + if status['cycode_installed']: + console.print('[green]✓ Cycode AI guardrails: INSTALLED[/]') + else: + console.print('[yellow]○ Cycode AI guardrails: NOT INSTALLED[/]') + + # Show hook details + table = Table(show_header=True, header_style='bold') + table.add_column('Hook Event') + table.add_column('Cycode Enabled') + table.add_column('Total Hooks') + + for event, info in status['hooks'].items(): + enabled = '[green]Yes[/]' if info['enabled'] else '[dim]No[/]' + table.add_row(event, enabled, str(info['total_entries'])) + + console.print(table) + + console.print() diff --git a/cycode/cli/apps/ai_guardrails/uninstall_command.py b/cycode/cli/apps/ai_guardrails/uninstall_command.py new file mode 100644 index 00000000..23315693 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/uninstall_command.py @@ -0,0 +1,73 @@ +"""Uninstall command for AI guardrails hooks.""" + +from pathlib import Path +from typing import Annotated, Optional + +import typer + +from cycode.cli.apps.ai_guardrails.command_utils import ( + console, + resolve_repo_path, + validate_and_parse_ide, + validate_scope, +) +from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS +from cycode.cli.apps.ai_guardrails.hooks_manager import uninstall_hooks +from cycode.cli.utils.sentry import add_breadcrumb + + +def uninstall_command( + ctx: typer.Context, + scope: Annotated[ + str, + typer.Option( + '--scope', + '-s', + help='Uninstall scope: "user" for user-level hooks, "repo" for repository-level hooks.', + ), + ] = 'user', + ide: Annotated[ + str, + typer.Option( + '--ide', + help='IDE to uninstall hooks from (e.g., "cursor"). Defaults to cursor.', + ), + ] = 'cursor', + repo_path: Annotated[ + Optional[Path], + typer.Option( + '--repo-path', + help='Repository path for repo-scoped uninstallation (defaults to current directory).', + exists=True, + file_okay=False, + dir_okay=True, + resolve_path=True, + ), + ] = None, +) -> None: + """Remove AI guardrails hooks from supported IDEs. + + This command removes Cycode hooks from the IDE's hooks configuration. + Other hooks (if any) will be preserved. + + Examples: + cycode ai-guardrails uninstall # Remove user-level hooks + cycode ai-guardrails uninstall --scope repo # Remove repo-level hooks + cycode ai-guardrails uninstall --ide cursor # Uninstall from Cursor IDE + """ + add_breadcrumb('ai-guardrails-uninstall') + + # Validate inputs + validate_scope(scope) + repo_path = resolve_repo_path(scope, repo_path) + ide_type = validate_and_parse_ide(ide) + ide_name = IDE_CONFIGS[ide_type].name + success, message = uninstall_hooks(scope, repo_path, ide=ide_type) + + if success: + console.print(f'[green]✓[/] {message}') + console.print() + console.print(f'[dim]Restart {ide_name} for changes to take effect.[/]') + else: + console.print(f'[red]✗[/] {message}', style='bold red') + raise typer.Exit(1) diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index d3e325f3..3ffefd0f 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -91,7 +91,7 @@ def _should_use_sync_flow(command_scan_type: str, scan_type: str, sync_option: b if not sync_option and scan_type != consts.IAC_SCAN_TYPE: return False - if command_scan_type not in {'path', 'repository'}: + if command_scan_type not in {'path', 'repository', 'ai_guardrails'}: return False if scan_type == consts.IAC_SCAN_TYPE: diff --git a/cycode/cli/cli_types.py b/cycode/cli/cli_types.py index 63a1cb36..bd88faea 100644 --- a/cycode/cli/cli_types.py +++ b/cycode/cli/cli_types.py @@ -86,6 +86,10 @@ def get_member_color(name: str) -> str: def get_member_emoji(name: str) -> str: return _SEVERITY_EMOJIS.get(name.lower(), _SEVERITY_DEFAULT_EMOJI) + @staticmethod + def get_member_unicode_emoji(name: str) -> str: + return _SEVERITY_UNICODE_EMOJIS.get(name.lower(), _SEVERITY_DEFAULT_UNICODE_EMOJI) + def __rich__(self) -> str: color = self.get_member_color(self.value) return f'[{color}]{self.value.upper()}[/]' @@ -117,3 +121,12 @@ def __rich__(self) -> str: SeverityOption.HIGH.value: ':red_circle:', SeverityOption.CRITICAL.value: ':exclamation_mark:', # double_exclamation_mark is not red } + +_SEVERITY_DEFAULT_UNICODE_EMOJI = '⚪' +_SEVERITY_UNICODE_EMOJIS = { + SeverityOption.INFO.value: '🔵', + SeverityOption.LOW.value: '🟡', + SeverityOption.MEDIUM.value: '🟠', + SeverityOption.HIGH.value: '🔴', + SeverityOption.CRITICAL.value: '❗', +} diff --git a/cycode/cli/utils/get_api_client.py b/cycode/cli/utils/get_api_client.py index 5c712288..b69666d3 100644 --- a/cycode/cli/utils/get_api_client.py +++ b/cycode/cli/utils/get_api_client.py @@ -3,11 +3,17 @@ import click from cycode.cli.user_settings.credentials_manager import CredentialsManager -from cycode.cyclient.client_creator import create_import_sbom_client, create_report_client, create_scan_client +from cycode.cyclient.client_creator import ( + create_ai_security_manager_client, + create_import_sbom_client, + create_report_client, + create_scan_client, +) if TYPE_CHECKING: import typer + from cycode.cyclient.ai_security_manager_client import AISecurityManagerClient from cycode.cyclient.import_sbom_client import ImportSbomClient from cycode.cyclient.report_client import ReportClient from cycode.cyclient.scan_client import ScanClient @@ -19,7 +25,7 @@ def _get_cycode_client( client_secret: Optional[str], hide_response_log: bool, id_token: Optional[str] = None, -) -> Union['ScanClient', 'ReportClient']: +) -> Union['ScanClient', 'ReportClient', 'ImportSbomClient', 'AISecurityManagerClient']: if client_id and id_token: return create_client_func(client_id, None, hide_response_log, id_token) @@ -62,6 +68,13 @@ def get_import_sbom_cycode_client(ctx: 'typer.Context', hide_response_log: bool return _get_cycode_client(create_import_sbom_client, client_id, client_secret, hide_response_log, id_token) +def get_ai_security_manager_client(ctx: 'typer.Context', hide_response_log: bool = True) -> 'AISecurityManagerClient': + client_id = ctx.obj.get('client_id') + client_secret = ctx.obj.get('client_secret') + id_token = ctx.obj.get('id_token') + return _get_cycode_client(create_ai_security_manager_client, client_id, client_secret, hide_response_log, id_token) + + def _get_configured_credentials() -> tuple[str, str]: credentials_manager = CredentialsManager() return credentials_manager.get_credentials() diff --git a/cycode/cli/utils/scan_utils.py b/cycode/cli/utils/scan_utils.py index 1332a7cf..be86716b 100644 --- a/cycode/cli/utils/scan_utils.py +++ b/cycode/cli/utils/scan_utils.py @@ -1,9 +1,12 @@ import os +from collections import defaultdict from typing import TYPE_CHECKING, Optional from uuid import UUID, uuid4 import typer +from cycode.cli.cli_types import SeverityOption + if TYPE_CHECKING: from cycode.cli.models import LocalScanResult from cycode.cyclient.models import ScanConfiguration @@ -33,3 +36,24 @@ def generate_unique_scan_id() -> UUID: return UUID(os.environ['PYTEST_TEST_UNIQUE_ID']) return uuid4() + + +def build_violation_summary(local_scan_results: list['LocalScanResult']) -> str: + """Build violation summary string with severity breakdown and emojis.""" + detections_count = 0 + severity_counts = defaultdict(int) + + for local_scan_result in local_scan_results: + for document_detections in local_scan_result.document_detections: + for detection in document_detections.detections: + if detection.severity: + detections_count += 1 + severity_counts[SeverityOption(detection.severity)] += 1 + + severity_parts = [] + for severity in reversed(SeverityOption): + emoji = SeverityOption.get_member_unicode_emoji(severity) + count = severity_counts[severity] + severity_parts.append(f'{emoji} {severity.upper()} - {count}') + + return f'Cycode found {detections_count} violations: {" | ".join(severity_parts)}' diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py new file mode 100644 index 00000000..627e2b33 --- /dev/null +++ b/cycode/cyclient/ai_security_manager_client.py @@ -0,0 +1,86 @@ +"""Client for AI Security Manager service.""" + +from typing import TYPE_CHECKING, Optional + +from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError +from cycode.cyclient.cycode_client_base import CycodeClientBase +from cycode.cyclient.logger import logger + +if TYPE_CHECKING: + from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload + from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType, AIHookOutcome, BlockReason + from cycode.cyclient.ai_security_manager_service_config import AISecurityManagerServiceConfigBase + + +class AISecurityManagerClient: + """Client for interacting with AI Security Manager service.""" + + _CONVERSATIONS_PATH = 'v4/ai-security/interactions/conversations' + _EVENTS_PATH = 'v4/ai-security/interactions/events' + + def __init__(self, client: CycodeClientBase, service_config: 'AISecurityManagerServiceConfigBase') -> None: + self.client = client + self.service_config = service_config + + def _build_endpoint_path(self, path: str) -> str: + """Build the full endpoint path including service name/port.""" + service_name = self.service_config.get_service_name() + if service_name: + return f'{service_name}/{path}' + return path + + def create_conversation(self, payload: 'AIHookPayload') -> Optional[str]: + """Creates an AI conversation from hook payload.""" + conversation_id = payload.conversation_id + if not conversation_id: + return None + + body = { + 'id': conversation_id, + 'ide_user_email': payload.ide_user_email, + 'model': payload.model, + 'ide_provider': payload.ide_provider, + 'ide_version': payload.ide_version, + } + + try: + self.client.post(self._build_endpoint_path(self._CONVERSATIONS_PATH), body=body) + except HttpUnauthorizedError: + # Authentication error - re-raise so prompt_command can catch it + raise + except Exception as e: + logger.debug('Failed to create conversation', exc_info=e) + # Don't fail the hook if tracking fails (non-auth errors) + + return conversation_id + + def create_event( + self, + payload: 'AIHookPayload', + event_type: 'AiHookEventType', + outcome: 'AIHookOutcome', + scan_id: Optional[str] = None, + block_reason: Optional['BlockReason'] = None, + ) -> None: + """Create an AI hook event from hook payload.""" + conversation_id = payload.conversation_id + if not conversation_id: + logger.debug('No conversation ID available, skipping event creation') + return + + body = { + 'conversation_id': conversation_id, + 'event_type': event_type, + 'outcome': outcome, + 'generation_id': payload.generation_id, + 'block_reason': block_reason, + 'cli_scan_id': scan_id, + 'mcp_server_name': payload.mcp_server_name, + 'mcp_tool_name': payload.mcp_tool_name, + } + + try: + self.client.post(self._build_endpoint_path(self._EVENTS_PATH), body=body) + except Exception as e: + logger.debug('Failed to create AI hook event', exc_info=e) + # Don't fail the hook if tracking fails diff --git a/cycode/cyclient/ai_security_manager_service_config.py b/cycode/cyclient/ai_security_manager_service_config.py new file mode 100644 index 00000000..60d7f2dd --- /dev/null +++ b/cycode/cyclient/ai_security_manager_service_config.py @@ -0,0 +1,27 @@ +"""Service configuration for AI Security Manager.""" + + +class AISecurityManagerServiceConfigBase: + """Base class for AI Security Manager service configuration.""" + + def get_service_name(self) -> str: + """Get the service name or port for URL construction. + + In dev mode, returns the port number. + In production, returns the service name. + """ + raise NotImplementedError + + +class DevAISecurityManagerServiceConfig(AISecurityManagerServiceConfigBase): + """Dev configuration for AI Security Manager.""" + + def get_service_name(self) -> str: + return '5163/api' + + +class DefaultAISecurityManagerServiceConfig(AISecurityManagerServiceConfigBase): + """Production configuration for AI Security Manager.""" + + def get_service_name(self) -> str: + return '' diff --git a/cycode/cyclient/client_creator.py b/cycode/cyclient/client_creator.py index 01ab6b59..c26795c7 100644 --- a/cycode/cyclient/client_creator.py +++ b/cycode/cyclient/client_creator.py @@ -1,5 +1,10 @@ from typing import Optional +from cycode.cyclient.ai_security_manager_client import AISecurityManagerClient +from cycode.cyclient.ai_security_manager_service_config import ( + DefaultAISecurityManagerServiceConfig, + DevAISecurityManagerServiceConfig, +) from cycode.cyclient.config import dev_mode from cycode.cyclient.config_dev import DEV_CYCODE_API_URL from cycode.cyclient.cycode_dev_based_client import CycodeDevBasedClient @@ -49,3 +54,18 @@ def create_import_sbom_client( else: client = CycodeTokenBasedClient(client_id, client_secret) return ImportSbomClient(client) + + +def create_ai_security_manager_client( + client_id: str, client_secret: Optional[str] = None, _: bool = False, id_token: Optional[str] = None +) -> AISecurityManagerClient: + if dev_mode: + client = CycodeDevBasedClient(DEV_CYCODE_API_URL) + service_config = DevAISecurityManagerServiceConfig() + else: + if id_token: + client = CycodeOidcBasedClient(client_id, id_token) + else: + client = CycodeTokenBasedClient(client_id, client_secret) + service_config = DefaultAISecurityManagerServiceConfig() + return AISecurityManagerClient(client, service_config) diff --git a/tests/cli/commands/ai_guardrails/__init__.py b/tests/cli/commands/ai_guardrails/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/commands/ai_guardrails/scan/__init__.py b/tests/cli/commands/ai_guardrails/scan/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/commands/ai_guardrails/scan/test_handlers.py b/tests/cli/commands/ai_guardrails/scan/test_handlers.py new file mode 100644 index 00000000..58dfe195 --- /dev/null +++ b/tests/cli/commands/ai_guardrails/scan/test_handlers.py @@ -0,0 +1,361 @@ +"""Tests for AI guardrails handlers.""" + +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.apps.ai_guardrails.scan.handlers import ( + handle_before_mcp_execution, + handle_before_read_file, + handle_before_submit_prompt, +) +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.types import AIHookOutcome, BlockReason + + +@pytest.fixture +def mock_ctx() -> MagicMock: + """Create a mock Typer context.""" + ctx = MagicMock(spec=typer.Context) + ctx.obj = { + 'ai_security_client': MagicMock(), + 'scan_type': 'secret', + } + return ctx + + +@pytest.fixture +def mock_payload() -> AIHookPayload: + """Create a mock AIHookPayload.""" + return AIHookPayload( + event_name='prompt', + conversation_id='test-conv-id', + generation_id='test-gen-id', + ide_user_email='test@example.com', + model='gpt-4', + ide_provider='cursor', + ide_version='1.0.0', + prompt='Test prompt', + ) + + +@pytest.fixture +def default_policy() -> dict[str, Any]: + """Create a default policy dict.""" + return { + 'mode': 'block', + 'fail_open': True, + 'secrets': {'max_bytes': 200000}, + 'prompt': {'enabled': True, 'action': 'block'}, + 'file_read': {'enabled': True, 'action': 'block', 'scan_content': True, 'deny_globs': []}, + 'mcp': {'enabled': True, 'action': 'block', 'scan_arguments': True}, + } + + +# Tests for handle_before_submit_prompt + + +def test_handle_before_submit_prompt_disabled( + mock_ctx: MagicMock, mock_payload: AIHookPayload, default_policy: dict[str, Any] +) -> None: + """Test that disabled prompt scanning allows the prompt.""" + default_policy['prompt']['enabled'] = False + + result = handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) + + assert result == {'continue': True} + mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_submit_prompt_no_secrets( + mock_scan: MagicMock, mock_ctx: MagicMock, mock_payload: AIHookPayload, default_policy: dict[str, Any] +) -> None: + """Test that prompt with no secrets is allowed.""" + mock_scan.return_value = (None, 'scan-id-123') + + result = handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) + + assert result == {'continue': True} + mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + # outcome is arg[2], scan_id and block_reason are kwargs + assert call_args.args[2] == AIHookOutcome.ALLOWED + assert call_args.kwargs['scan_id'] == 'scan-id-123' + assert call_args.kwargs['block_reason'] is None + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_submit_prompt_with_secrets_blocked( + mock_scan: MagicMock, mock_ctx: MagicMock, mock_payload: AIHookPayload, default_policy: dict[str, Any] +) -> None: + """Test that prompt with secrets is blocked.""" + mock_scan.return_value = ('Found 1 secret: API key', 'scan-id-456') + + result = handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) + + assert result['continue'] is False + assert 'Found 1 secret: API key' in result['user_message'] + mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.BLOCKED + assert call_args.kwargs['block_reason'] == BlockReason.SECRETS_IN_PROMPT + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_submit_prompt_with_secrets_warned( + mock_scan: MagicMock, mock_ctx: MagicMock, mock_payload: AIHookPayload, default_policy: dict[str, Any] +) -> None: + """Test that prompt with secrets in warn mode is allowed.""" + default_policy['prompt']['action'] = 'warn' + mock_scan.return_value = ('Found 1 secret: API key', 'scan-id-789') + + result = handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) + + assert result == {'continue': True} + mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.WARNED + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_submit_prompt_scan_failure_fail_open( + mock_scan: MagicMock, mock_ctx: MagicMock, mock_payload: AIHookPayload, default_policy: dict[str, Any] +) -> None: + """Test that scan failure with fail_open=True allows the prompt.""" + mock_scan.side_effect = RuntimeError('Scan failed') + default_policy['fail_open'] = True + + with pytest.raises(RuntimeError): + handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) + + # Event should be tracked even on exception + mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.ALLOWED + # When fail_open=True, no block_reason since action is allowed + assert call_args.kwargs['block_reason'] is None + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_submit_prompt_scan_failure_fail_closed( + mock_scan: MagicMock, mock_ctx: MagicMock, mock_payload: AIHookPayload, default_policy: dict[str, Any] +) -> None: + """Test that scan failure with fail_open=False blocks the prompt.""" + mock_scan.side_effect = RuntimeError('Scan failed') + default_policy['fail_open'] = False + + with pytest.raises(RuntimeError): + handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) + + # Event should be tracked even on exception + mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.BLOCKED + assert call_args.kwargs['block_reason'] == BlockReason.SCAN_FAILURE + + +# Tests for handle_before_read_file + + +def test_handle_before_read_file_disabled(mock_ctx: MagicMock, default_policy: dict[str, Any]) -> None: + """Test that disabled file read scanning allows the file.""" + default_policy['file_read']['enabled'] = False + payload = AIHookPayload( + event_name='file_read', + ide_provider='cursor', + file_path='/path/to/file.txt', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + assert result == {'permission': 'allow'} + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') +def test_handle_before_read_file_sensitive_path( + mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that sensitive path is blocked.""" + mock_is_denied.return_value = True + payload = AIHookPayload( + event_name='file_read', + ide_provider='cursor', + file_path='/path/to/.env', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + assert result['permission'] == 'deny' + assert '.env' in result['user_message'] + mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.BLOCKED + assert call_args.kwargs['block_reason'] == BlockReason.SENSITIVE_PATH + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_path_for_secrets') +def test_handle_before_read_file_no_secrets( + mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that file with no secrets is allowed.""" + mock_is_denied.return_value = False + mock_scan.return_value = (None, 'scan-id-123') + payload = AIHookPayload( + event_name='file_read', + ide_provider='cursor', + file_path='/path/to/file.txt', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + assert result == {'permission': 'allow'} + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.ALLOWED + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_path_for_secrets') +def test_handle_before_read_file_with_secrets( + mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that file with secrets is blocked.""" + mock_is_denied.return_value = False + mock_scan.return_value = ('Found 1 secret: password', 'scan-id-456') + payload = AIHookPayload( + event_name='file_read', + ide_provider='cursor', + file_path='/path/to/file.txt', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + assert result['permission'] == 'deny' + assert 'Found 1 secret: password' in result['user_message'] + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.BLOCKED + assert call_args.kwargs['block_reason'] == BlockReason.SECRETS_IN_FILE + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_path_for_secrets') +def test_handle_before_read_file_scan_disabled( + mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that file is allowed when content scanning is disabled.""" + mock_is_denied.return_value = False + default_policy['file_read']['scan_content'] = False + payload = AIHookPayload( + event_name='file_read', + ide_provider='cursor', + file_path='/path/to/file.txt', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + assert result == {'permission': 'allow'} + mock_scan.assert_not_called() + + +# Tests for handle_before_mcp_execution + + +def test_handle_before_mcp_execution_disabled(mock_ctx: MagicMock, default_policy: dict[str, Any]) -> None: + """Test that disabled MCP scanning allows the execution.""" + default_policy['mcp']['enabled'] = False + payload = AIHookPayload( + event_name='mcp_execution', + ide_provider='cursor', + mcp_tool_name='test_tool', + mcp_arguments={'arg1': 'value1'}, + ) + + result = handle_before_mcp_execution(mock_ctx, payload, default_policy) + + assert result == {'permission': 'allow'} + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_mcp_execution_no_secrets( + mock_scan: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that MCP execution with no secrets is allowed.""" + mock_scan.return_value = (None, 'scan-id-123') + payload = AIHookPayload( + event_name='mcp_execution', + ide_provider='cursor', + mcp_tool_name='test_tool', + mcp_arguments={'arg1': 'value1'}, + ) + + result = handle_before_mcp_execution(mock_ctx, payload, default_policy) + + assert result == {'permission': 'allow'} + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.ALLOWED + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_mcp_execution_with_secrets_blocked( + mock_scan: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that MCP execution with secrets is blocked.""" + mock_scan.return_value = ('Found 1 secret: token', 'scan-id-456') + payload = AIHookPayload( + event_name='mcp_execution', + ide_provider='cursor', + mcp_tool_name='test_tool', + mcp_arguments={'arg1': 'secret_token_12345'}, + ) + + result = handle_before_mcp_execution(mock_ctx, payload, default_policy) + + assert result['permission'] == 'deny' + assert 'Found 1 secret: token' in result['user_message'] + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.BLOCKED + assert call_args.kwargs['block_reason'] == BlockReason.SECRETS_IN_MCP_ARGS + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_mcp_execution_with_secrets_warned( + mock_scan: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that MCP execution with secrets in warn mode asks permission.""" + mock_scan.return_value = ('Found 1 secret: token', 'scan-id-789') + default_policy['mcp']['action'] = 'warn' + payload = AIHookPayload( + event_name='mcp_execution', + ide_provider='cursor', + mcp_tool_name='test_tool', + mcp_arguments={'arg1': 'secret_token_12345'}, + ) + + result = handle_before_mcp_execution(mock_ctx, payload, default_policy) + + assert result['permission'] == 'ask' + assert 'Found 1 secret: token' in result['user_message'] + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.WARNED + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_mcp_execution_scan_disabled( + mock_scan: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that MCP execution is allowed when argument scanning is disabled.""" + default_policy['mcp']['scan_arguments'] = False + payload = AIHookPayload( + event_name='mcp_execution', + ide_provider='cursor', + mcp_tool_name='test_tool', + mcp_arguments={'arg1': 'value1'}, + ) + + result = handle_before_mcp_execution(mock_ctx, payload, default_policy) + + assert result == {'permission': 'allow'} + mock_scan.assert_not_called() diff --git a/tests/cli/commands/ai_guardrails/scan/test_payload.py b/tests/cli/commands/ai_guardrails/scan/test_payload.py new file mode 100644 index 00000000..9d14dda3 --- /dev/null +++ b/tests/cli/commands/ai_guardrails/scan/test_payload.py @@ -0,0 +1,135 @@ +"""Tests for AI hook payload normalization.""" + +import pytest + +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType + + +def test_from_cursor_payload_prompt_event() -> None: + """Test conversion of Cursor beforeSubmitPrompt payload.""" + cursor_payload = { + 'hook_event_name': 'beforeSubmitPrompt', + 'conversation_id': 'conv-123', + 'generation_id': 'gen-456', + 'user_email': 'user@example.com', + 'model': 'gpt-4', + 'cursor_version': '0.42.0', + 'prompt': 'Test prompt', + } + + unified = AIHookPayload.from_cursor_payload(cursor_payload) + + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id == 'conv-123' + assert unified.generation_id == 'gen-456' + assert unified.ide_user_email == 'user@example.com' + assert unified.model == 'gpt-4' + assert unified.ide_provider == 'cursor' + assert unified.ide_version == '0.42.0' + assert unified.prompt == 'Test prompt' + + +def test_from_cursor_payload_file_read_event() -> None: + """Test conversion of Cursor beforeReadFile payload.""" + cursor_payload = { + 'hook_event_name': 'beforeReadFile', + 'conversation_id': 'conv-123', + 'file_path': '/path/to/secret.env', + } + + unified = AIHookPayload.from_cursor_payload(cursor_payload) + + assert unified.event_name == AiHookEventType.FILE_READ + assert unified.file_path == '/path/to/secret.env' + assert unified.ide_provider == 'cursor' + + +def test_from_cursor_payload_mcp_execution_event() -> None: + """Test conversion of Cursor beforeMCPExecution payload.""" + cursor_payload = { + 'hook_event_name': 'beforeMCPExecution', + 'conversation_id': 'conv-123', + 'command': 'GitLab', + 'tool_name': 'discussion_list', + 'arguments': {'resource_type': 'merge_request', 'parent_id': 'organization/repo', 'resource_id': '4'}, + } + + unified = AIHookPayload.from_cursor_payload(cursor_payload) + + assert unified.event_name == AiHookEventType.MCP_EXECUTION + assert unified.mcp_server_name == 'GitLab' + assert unified.mcp_tool_name == 'discussion_list' + assert unified.mcp_arguments == { + 'resource_type': 'merge_request', + 'parent_id': 'organization/repo', + 'resource_id': '4', + } + + +def test_from_cursor_payload_with_alternative_field_names() -> None: + """Test that alternative field names are handled (path vs file_path, etc.).""" + cursor_payload = { + 'hook_event_name': 'beforeReadFile', + 'path': '/alternative/path.txt', # Alternative to file_path + } + + unified = AIHookPayload.from_cursor_payload(cursor_payload) + assert unified.file_path == '/alternative/path.txt' + + cursor_payload = { + 'hook_event_name': 'beforeMCPExecution', + 'tool': 'my_tool', # Alternative to tool_name + 'tool_input': {'key': 'value'}, # Alternative to arguments + } + + unified = AIHookPayload.from_cursor_payload(cursor_payload) + assert unified.mcp_tool_name == 'my_tool' + assert unified.mcp_arguments == {'key': 'value'} + + +def test_from_cursor_payload_unknown_event() -> None: + """Test that unknown event names are passed through as-is.""" + cursor_payload = { + 'hook_event_name': 'unknownEvent', + 'conversation_id': 'conv-123', + } + + unified = AIHookPayload.from_cursor_payload(cursor_payload) + # Unknown events fall back to original name + assert unified.event_name == 'unknownEvent' + + +def test_from_payload_cursor() -> None: + """Test from_payload dispatcher with Cursor tool.""" + cursor_payload = { + 'hook_event_name': 'beforeSubmitPrompt', + 'prompt': 'test', + } + + unified = AIHookPayload.from_payload(cursor_payload, tool='cursor') + assert unified.event_name == AiHookEventType.PROMPT + assert unified.ide_provider == 'cursor' + + +def test_from_payload_unsupported_tool() -> None: + """Test from_payload raises ValueError for unsupported tools.""" + payload = {'hook_event_name': 'someEvent'} + + with pytest.raises(ValueError, match='Unsupported IDE/tool: unsupported'): + AIHookPayload.from_payload(payload, tool='unsupported') + + +def test_from_cursor_payload_empty_fields() -> None: + """Test handling of empty/missing fields.""" + cursor_payload = { + 'hook_event_name': 'beforeSubmitPrompt', + # Most fields missing + } + + unified = AIHookPayload.from_cursor_payload(cursor_payload) + + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id is None + assert unified.prompt == '' # Default to empty string + assert unified.ide_provider == 'cursor' diff --git a/tests/cli/commands/ai_guardrails/scan/test_policy.py b/tests/cli/commands/ai_guardrails/scan/test_policy.py new file mode 100644 index 00000000..bbe884b0 --- /dev/null +++ b/tests/cli/commands/ai_guardrails/scan/test_policy.py @@ -0,0 +1,199 @@ +"""Tests for AI guardrails policy loading and management.""" + +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +from pyfakefs.fake_filesystem import FakeFilesystem + +from cycode.cli.apps.ai_guardrails.scan.policy import ( + deep_merge, + get_policy_value, + load_defaults, + load_policy, + load_yaml_file, +) + + +def test_deep_merge_simple() -> None: + """Test deep merging two simple dictionaries.""" + base = {'a': 1, 'b': 2} + override = {'b': 3, 'c': 4} + result = deep_merge(base, override) + + assert result == {'a': 1, 'b': 3, 'c': 4} + + +def test_deep_merge_nested() -> None: + """Test deep merging nested dictionaries.""" + base = {'level1': {'level2': {'key1': 'value1', 'key2': 'value2'}}} + override = {'level1': {'level2': {'key2': 'override2', 'key3': 'value3'}}} + result = deep_merge(base, override) + + assert result == {'level1': {'level2': {'key1': 'value1', 'key2': 'override2', 'key3': 'value3'}}} + + +def test_deep_merge_override_with_non_dict() -> None: + """Test that non-dict overrides replace the base value entirely.""" + base = {'key': {'nested': 'value'}} + override = {'key': 'simple_value'} + result = deep_merge(base, override) + + assert result == {'key': 'simple_value'} + + +def test_load_yaml_file_nonexistent(fs: FakeFilesystem) -> None: + """Test loading a non-existent file returns None.""" + result = load_yaml_file(Path('/fake/nonexistent.yaml')) + assert result is None + + +def test_load_yaml_file_valid_yaml(fs: FakeFilesystem) -> None: + """Test loading a valid YAML file.""" + fs.create_file('/fake/config.yaml', contents='mode: block\nfail_open: true\n') + + result = load_yaml_file(Path('/fake/config.yaml')) + assert result == {'mode': 'block', 'fail_open': True} + + +def test_load_yaml_file_valid_json(fs: FakeFilesystem) -> None: + """Test loading a valid JSON file.""" + fs.create_file('/fake/config.json', contents='{"mode": "block", "fail_open": true}') + + result = load_yaml_file(Path('/fake/config.json')) + assert result == {'mode': 'block', 'fail_open': True} + + +def test_load_yaml_file_invalid_yaml(fs: FakeFilesystem) -> None: + """Test loading an invalid YAML file returns None.""" + fs.create_file('/fake/invalid.yaml', contents='{ invalid yaml content [') + + result = load_yaml_file(Path('/fake/invalid.yaml')) + assert result is None + + +def test_load_defaults() -> None: + """Test that load_defaults returns a dict with expected keys.""" + defaults = load_defaults() + + assert isinstance(defaults, dict) + assert 'mode' in defaults + assert 'fail_open' in defaults + assert 'prompt' in defaults + assert 'file_read' in defaults + assert 'mcp' in defaults + + +def test_get_policy_value_single_key() -> None: + """Test getting a single-level value.""" + policy = {'mode': 'block', 'fail_open': True} + + assert get_policy_value(policy, 'mode') == 'block' + assert get_policy_value(policy, 'fail_open') is True + + +def test_get_policy_value_nested_keys() -> None: + """Test getting a nested value.""" + policy = {'prompt': {'enabled': True, 'action': 'block'}} + + assert get_policy_value(policy, 'prompt', 'enabled') is True + assert get_policy_value(policy, 'prompt', 'action') == 'block' + + +def test_get_policy_value_missing_key() -> None: + """Test that missing keys return the default value.""" + policy = {'mode': 'block'} + + assert get_policy_value(policy, 'nonexistent', default='default_value') == 'default_value' + + +def test_get_policy_value_deeply_nested() -> None: + """Test getting deeply nested values.""" + policy = {'level1': {'level2': {'level3': 'value'}}} + + assert get_policy_value(policy, 'level1', 'level2', 'level3') == 'value' + assert get_policy_value(policy, 'level1', 'level2', 'missing', default='def') == 'def' + + +def test_get_policy_value_non_dict_in_path() -> None: + """Test that non-dict values in path return default.""" + policy = {'key': 'string_value'} + + # Trying to access nested key on non-dict should return default + assert get_policy_value(policy, 'key', 'nested', default='default') == 'default' + + +@patch('cycode.cli.apps.ai_guardrails.scan.policy.load_yaml_file') +def test_load_policy_defaults_only(mock_load: MagicMock) -> None: + """Test loading policy with only defaults (no user or repo config).""" + mock_load.return_value = None # No user or repo config + + policy = load_policy() + + assert 'mode' in policy + assert 'fail_open' in policy + + +@patch('pathlib.Path.home') +def test_load_policy_with_user_config(mock_home: MagicMock, fs: FakeFilesystem) -> None: + """Test loading policy with user config override.""" + mock_home.return_value = Path('/home/testuser') + + # Create user config in fake filesystem + fs.create_file('/home/testuser/.cycode/ai-guardrails.yaml', contents='mode: warn\nfail_open: false\n') + + policy = load_policy() + + # User config should override defaults + assert policy['mode'] == 'warn' + assert policy['fail_open'] is False + + +@patch('cycode.cli.apps.ai_guardrails.scan.policy.load_yaml_file') +def test_load_policy_with_repo_config(mock_load: MagicMock) -> None: + """Test loading policy with repo config (highest precedence).""" + repo_path = Path('/fake/repo') + repo_config = repo_path / '.cycode' / 'ai-guardrails.yaml' + + def side_effect(path: Path) -> Optional[dict]: + if path == repo_config: + return {'mode': 'block', 'prompt': {'enabled': False}} + return None + + mock_load.side_effect = side_effect + + policy = load_policy(str(repo_path)) + + # Repo config should have highest precedence + assert policy['mode'] == 'block' + assert policy['prompt']['enabled'] is False + + +@patch('pathlib.Path.home') +def test_load_policy_precedence(mock_home: MagicMock, fs: FakeFilesystem) -> None: + """Test that policy precedence is: defaults < user < repo.""" + mock_home.return_value = Path('/home/testuser') + + # Create user config + fs.create_file('/home/testuser/.cycode/ai-guardrails.yaml', contents='mode: warn\nfail_open: false\n') + + # Create repo config + fs.create_file('/fake/repo/.cycode/ai-guardrails.yaml', contents='mode: block\n') + + policy = load_policy('/fake/repo') + + # mode should come from repo (highest precedence) + assert policy['mode'] == 'block' + # fail_open should come from user config (repo doesn't override it) + assert policy['fail_open'] is False + + +@patch('cycode.cli.apps.ai_guardrails.scan.policy.load_yaml_file') +def test_load_policy_none_workspace_root(mock_load: MagicMock) -> None: + """Test that None workspace_root is handled correctly.""" + mock_load.return_value = None + + policy = load_policy(None) + + # Should only load defaults (no repo config) + assert 'mode' in policy diff --git a/tests/cli/commands/ai_guardrails/scan/test_response_builders.py b/tests/cli/commands/ai_guardrails/scan/test_response_builders.py new file mode 100644 index 00000000..86e87ca7 --- /dev/null +++ b/tests/cli/commands/ai_guardrails/scan/test_response_builders.py @@ -0,0 +1,79 @@ +"""Tests for IDE response builders.""" + +import pytest + +from cycode.cli.apps.ai_guardrails.scan.response_builders import ( + CursorResponseBuilder, + IDEResponseBuilder, + get_response_builder, +) + + +def test_cursor_response_builder_allow_permission() -> None: + """Test Cursor allow permission response.""" + builder = CursorResponseBuilder() + response = builder.allow_permission() + + assert response == {'permission': 'allow'} + + +def test_cursor_response_builder_deny_permission() -> None: + """Test Cursor deny permission response with messages.""" + builder = CursorResponseBuilder() + response = builder.deny_permission('User message', 'Agent message') + + assert response == { + 'permission': 'deny', + 'user_message': 'User message', + 'agent_message': 'Agent message', + } + + +def test_cursor_response_builder_ask_permission() -> None: + """Test Cursor ask permission response for warnings.""" + builder = CursorResponseBuilder() + response = builder.ask_permission('Warning message', 'Agent warning') + + assert response == { + 'permission': 'ask', + 'user_message': 'Warning message', + 'agent_message': 'Agent warning', + } + + +def test_cursor_response_builder_allow_prompt() -> None: + """Test Cursor allow prompt response.""" + builder = CursorResponseBuilder() + response = builder.allow_prompt() + + assert response == {'continue': True} + + +def test_cursor_response_builder_deny_prompt() -> None: + """Test Cursor deny prompt response with message.""" + builder = CursorResponseBuilder() + response = builder.deny_prompt('Secrets detected') + + assert response == {'continue': False, 'user_message': 'Secrets detected'} + + +def test_get_response_builder_cursor() -> None: + """Test getting Cursor response builder.""" + builder = get_response_builder('cursor') + + assert isinstance(builder, CursorResponseBuilder) + assert isinstance(builder, IDEResponseBuilder) + + +def test_get_response_builder_unsupported() -> None: + """Test that unsupported IDE raises ValueError.""" + with pytest.raises(ValueError, match='Unsupported IDE: unknown'): + get_response_builder('unknown') + + +def test_cursor_response_builder_is_singleton() -> None: + """Test that getting the same builder returns the same instance.""" + builder1 = get_response_builder('cursor') + builder2 = get_response_builder('cursor') + + assert builder1 is builder2 diff --git a/tests/cli/commands/ai_guardrails/scan/test_utils.py b/tests/cli/commands/ai_guardrails/scan/test_utils.py new file mode 100644 index 00000000..ce84c609 --- /dev/null +++ b/tests/cli/commands/ai_guardrails/scan/test_utils.py @@ -0,0 +1,113 @@ +"""Tests for AI guardrails utility functions.""" + +from cycode.cli.apps.ai_guardrails.scan.utils import ( + is_denied_path, + matches_glob, + normalize_path, +) + + +def test_normalize_path_rejects_escape() -> None: + """Test that paths attempting to escape are rejected.""" + path = '../../../etc/passwd' + result = normalize_path(path) + + assert result == '' + + +def test_normalize_path_empty() -> None: + """Test normalizing empty path.""" + result = normalize_path('') + + assert result == '' + + +def test_matches_glob_simple() -> None: + """Test simple glob pattern matching.""" + assert matches_glob('secret.env', '*.env') is True + assert matches_glob('secret.txt', '*.env') is False + + +def test_matches_glob_recursive() -> None: + """Test recursive glob pattern with **.""" + assert matches_glob('path/to/secret.env', '**/*.env') is True + # Note: '**/*.env' requires at least one path separator, so 'secret.env' won't match + assert matches_glob('secret.env', '*.env') is True # Use non-recursive pattern instead + assert matches_glob('path/to/file.txt', '**/*.env') is False + + +def test_matches_glob_directory() -> None: + """Test matching files in specific directories.""" + assert matches_glob('.env', '.env') is True + assert matches_glob('config/.env', '**/.env') is True + assert matches_glob('other/file', '**/.env') is False + + +def test_matches_glob_case_insensitive() -> None: + """Test that glob matching handles case variations.""" + # Case-insensitive matching for cross-platform compatibility + assert matches_glob('secret.env', '*.env') is True + assert matches_glob('SECRET.ENV', '*.env') is True # Uppercase path matches lowercase pattern + assert matches_glob('Secret.Env', '*.env') is True # Mixed case matches + assert matches_glob('secret.env', '*.ENV') is True # Lowercase path matches uppercase pattern + assert matches_glob('SECRET.ENV', '*.ENV') is True # Both uppercase match + + +def test_matches_glob_empty_inputs() -> None: + """Test glob matching with empty inputs.""" + assert matches_glob('', '*.env') is False + assert matches_glob('file.env', '') is False + assert matches_glob('', '') is False + + +def test_matches_glob_with_traversal_attempt() -> None: + """Test that path traversal is normalized before matching.""" + # Path traversal attempts should be normalized + assert matches_glob('../secret.env', '*.env') is False + + +def test_is_denied_path_with_deny_globs() -> None: + """Test path denial with deny_globs policy.""" + policy = {'file_read': {'deny_globs': ['*.env', '.git/*', '**/secrets/*']}} + + assert is_denied_path('.env', policy) is True + # Note: Path.match('*.env') matches paths ending with .env, including nested paths + assert is_denied_path('config/.env', policy) is True # Matches *.env + assert is_denied_path('.git/config', policy) is True # Matches .git/* + assert is_denied_path('app/secrets/api_keys.txt', policy) is True # Matches **/secrets/* + assert is_denied_path('app/config.yaml', policy) is False + + +def test_is_denied_path_nested_patterns() -> None: + """Test denial with various nesting patterns.""" + policy = {'file_read': {'deny_globs': ['*.key', '**/*.key', 'config/*.env']}} + + # *.key matches .key files at root level, **/*.key for nested + assert is_denied_path('private.key', policy) is True + assert is_denied_path('app/private.key', policy) is True + # config/*.env only matches .env files directly in config/ + assert is_denied_path('config/app.env', policy) is True + assert is_denied_path('config/sub/app.env', policy) is False # Not direct child + assert is_denied_path('app/config.yaml', policy) is False + + +def test_is_denied_path_empty_globs() -> None: + """Test that empty deny_globs list denies nothing.""" + policy = {'file_read': {'deny_globs': []}} + + assert is_denied_path('.env', policy) is False + assert is_denied_path('any/path', policy) is False + + +def test_is_denied_path_no_policy() -> None: + """Test denial with missing policy configuration.""" + policy = {} + + assert is_denied_path('.env', policy) is False + + +def test_is_denied_path_empty_path() -> None: + """Test denial check with empty path.""" + policy = {'file_read': {'deny_globs': ['*.env']}} + + assert is_denied_path('', policy) is False diff --git a/tests/cli/commands/ai_guardrails/test_command_utils.py b/tests/cli/commands/ai_guardrails/test_command_utils.py new file mode 100644 index 00000000..4f0ef55e --- /dev/null +++ b/tests/cli/commands/ai_guardrails/test_command_utils.py @@ -0,0 +1,57 @@ +"""Tests for AI guardrails command utilities.""" + +import pytest +import typer + +from cycode.cli.apps.ai_guardrails.command_utils import ( + validate_and_parse_ide, + validate_scope, +) +from cycode.cli.apps.ai_guardrails.consts import AIIDEType + + +def test_validate_and_parse_ide_valid() -> None: + """Test parsing valid IDE names.""" + assert validate_and_parse_ide('cursor') == AIIDEType.CURSOR + assert validate_and_parse_ide('CURSOR') == AIIDEType.CURSOR + assert validate_and_parse_ide('CuRsOr') == AIIDEType.CURSOR + + +def test_validate_and_parse_ide_invalid() -> None: + """Test that invalid IDE raises typer.Exit.""" + with pytest.raises(typer.Exit) as exc_info: + validate_and_parse_ide('invalid_ide') + assert exc_info.value.exit_code == 1 + + +def test_validate_scope_valid_default() -> None: + """Test validating valid scope with default allowed scopes.""" + # Should not raise any exception + validate_scope('user') + validate_scope('repo') + + +def test_validate_scope_invalid_default() -> None: + """Test that invalid scope raises typer.Exit with default allowed scopes.""" + with pytest.raises(typer.Exit) as exc_info: + validate_scope('invalid') + assert exc_info.value.exit_code == 1 + + with pytest.raises(typer.Exit) as exc_info: + validate_scope('all') # 'all' not in default allowed scopes + assert exc_info.value.exit_code == 1 + + +def test_validate_scope_valid_custom() -> None: + """Test validating scope with custom allowed scopes.""" + # Should not raise any exception + validate_scope('user', allowed_scopes=('user', 'repo', 'all')) + validate_scope('repo', allowed_scopes=('user', 'repo', 'all')) + validate_scope('all', allowed_scopes=('user', 'repo', 'all')) + + +def test_validate_scope_invalid_custom() -> None: + """Test that invalid scope raises typer.Exit with custom allowed scopes.""" + with pytest.raises(typer.Exit) as exc_info: + validate_scope('invalid', allowed_scopes=('user', 'repo', 'all')) + assert exc_info.value.exit_code == 1 From dcee451a4337b7207254bbd203670228658cab2e Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Wed, 4 Feb 2026 11:25:25 +0200 Subject: [PATCH 228/257] CM-58331 support claude code (#379) Co-authored-by: Claude Opus 4.5 --- .../cli/apps/ai_guardrails/command_utils.py | 12 +- cycode/cli/apps/ai_guardrails/consts.py | 81 +++++- .../cli/apps/ai_guardrails/hooks_manager.py | 32 ++- .../cli/apps/ai_guardrails/install_command.py | 39 ++- .../cli/apps/ai_guardrails/scan/handlers.py | 89 ++++--- cycode/cli/apps/ai_guardrails/scan/payload.py | 210 ++++++++++++++- .../ai_guardrails/scan/response_builders.py | 62 ++++- .../apps/ai_guardrails/scan/scan_command.py | 13 +- cycode/cli/apps/ai_guardrails/scan/types.py | 11 + .../cli/apps/ai_guardrails/status_command.py | 55 ++-- .../apps/ai_guardrails/uninstall_command.py | 39 ++- cycode/cyclient/ai_security_manager_client.py | 2 + .../ai_guardrails/scan/test_handlers.py | 4 +- .../ai_guardrails/scan/test_payload.py | 241 ++++++++++++++++++ .../scan/test_response_builders.py | 69 +++++ .../ai_guardrails/scan/test_scan_command.py | 138 ++++++++++ .../ai_guardrails/test_command_utils.py | 3 + .../ai_guardrails/test_hooks_manager.py | 53 ++++ 18 files changed, 1039 insertions(+), 114 deletions(-) create mode 100644 tests/cli/commands/ai_guardrails/scan/test_scan_command.py create mode 100644 tests/cli/commands/ai_guardrails/test_hooks_manager.py diff --git a/cycode/cli/apps/ai_guardrails/command_utils.py b/cycode/cli/apps/ai_guardrails/command_utils.py index e010f0a2..edc3104a 100644 --- a/cycode/cli/apps/ai_guardrails/command_utils.py +++ b/cycode/cli/apps/ai_guardrails/command_utils.py @@ -12,24 +12,26 @@ console = Console() -def validate_and_parse_ide(ide: str) -> AIIDEType: - """Validate IDE parameter and convert to AIIDEType enum. +def validate_and_parse_ide(ide: str) -> Optional[AIIDEType]: + """Validate IDE parameter, returning None for 'all'. Args: - ide: IDE name string (e.g., 'cursor') + ide: IDE name string (e.g., 'cursor', 'claude-code', 'all') Returns: - AIIDEType enum value + AIIDEType enum value, or None if 'all' was specified Raises: typer.Exit: If IDE is invalid """ + if ide.lower() == 'all': + return None try: return AIIDEType(ide.lower()) except ValueError: valid_ides = ', '.join([ide_type.value for ide_type in AIIDEType]) console.print( - f'[red]Error:[/] Invalid IDE "{ide}". Supported IDEs: {valid_ides}', + f'[red]Error:[/] Invalid IDE "{ide}". Supported IDEs: {valid_ides}, all', style='bold red', ) raise typer.Exit(1) from None diff --git a/cycode/cli/apps/ai_guardrails/consts.py b/cycode/cli/apps/ai_guardrails/consts.py index 21d89a3f..8714ec10 100644 --- a/cycode/cli/apps/ai_guardrails/consts.py +++ b/cycode/cli/apps/ai_guardrails/consts.py @@ -2,12 +2,7 @@ Currently supports: - Cursor - -To add a new IDE (e.g., Claude Code): -1. Add new value to AIIDEType enum -2. Create _get__hooks_dir() function with platform-specific paths -3. Add entry to IDE_CONFIGS dict with IDE-specific hook event names -4. Unhide --ide option in commands (install, uninstall, status) +- Claude Code """ import platform @@ -20,6 +15,14 @@ class AIIDEType(str, Enum): """Supported AI IDE types.""" CURSOR = 'cursor' + CLAUDE_CODE = 'claude-code' + + +class PolicyMode(str, Enum): + """Policy enforcement mode for global mode and per-feature actions.""" + + BLOCK = 'block' + WARN = 'warn' class IDEConfig(NamedTuple): @@ -42,6 +45,14 @@ def _get_cursor_hooks_dir() -> Path: return Path.home() / '.config' / 'Cursor' +def _get_claude_code_hooks_dir() -> Path: + """Get Claude Code hooks directory. + + Claude Code uses ~/.claude on all platforms. + """ + return Path.home() / '.claude' + + # IDE-specific configurations IDE_CONFIGS: dict[AIIDEType, IDEConfig] = { AIIDEType.CURSOR: IDEConfig( @@ -51,6 +62,13 @@ def _get_cursor_hooks_dir() -> Path: hooks_file_name='hooks.json', hook_events=['beforeSubmitPrompt', 'beforeReadFile', 'beforeMCPExecution'], ), + AIIDEType.CLAUDE_CODE: IDEConfig( + name='Claude Code', + hooks_dir=_get_claude_code_hooks_dir(), + repo_hooks_subdir='.claude', + hooks_file_name='settings.json', + hook_events=['UserPromptSubmit', 'PreToolUse:Read', 'PreToolUse:mcp'], + ), } # Default IDE @@ -60,6 +78,47 @@ def _get_cursor_hooks_dir() -> Path: CYCODE_SCAN_PROMPT_COMMAND = 'cycode ai-guardrails scan' +def _get_cursor_hooks_config() -> dict: + """Get Cursor-specific hooks configuration.""" + config = IDE_CONFIGS[AIIDEType.CURSOR] + hooks = {event: [{'command': CYCODE_SCAN_PROMPT_COMMAND}] for event in config.hook_events} + + return { + 'version': 1, + 'hooks': hooks, + } + + +def _get_claude_code_hooks_config() -> dict: + """Get Claude Code-specific hooks configuration. + + Claude Code uses a different hook format with nested structure: + - hooks are arrays of objects with 'hooks' containing command arrays + - PreToolUse uses 'matcher' field to specify which tools to intercept + """ + command = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide claude-code' + + return { + 'hooks': { + 'UserPromptSubmit': [ + { + 'hooks': [{'type': 'command', 'command': command}], + } + ], + 'PreToolUse': [ + { + 'matcher': 'Read', + 'hooks': [{'type': 'command', 'command': command}], + }, + { + 'matcher': 'mcp__.*', + 'hooks': [{'type': 'command', 'command': command}], + }, + ], + }, + } + + def get_hooks_config(ide: AIIDEType) -> dict: """Get the hooks configuration for a specific IDE. @@ -69,10 +128,6 @@ def get_hooks_config(ide: AIIDEType) -> dict: Returns: Dict with hooks configuration for the specified IDE """ - config = IDE_CONFIGS[ide] - hooks = {event: [{'command': CYCODE_SCAN_PROMPT_COMMAND}] for event in config.hook_events} - - return { - 'version': 1, - 'hooks': hooks, - } + if ide == AIIDEType.CLAUDE_CODE: + return _get_claude_code_hooks_config() + return _get_cursor_hooks_config() diff --git a/cycode/cli/apps/ai_guardrails/hooks_manager.py b/cycode/cli/apps/ai_guardrails/hooks_manager.py index 42f879f6..b8d43c43 100644 --- a/cycode/cli/apps/ai_guardrails/hooks_manager.py +++ b/cycode/cli/apps/ai_guardrails/hooks_manager.py @@ -59,9 +59,27 @@ def save_hooks_file(hooks_path: Path, hooks_config: dict) -> bool: def is_cycode_hook_entry(entry: dict) -> bool: - """Check if a hook entry is from cycode-cli.""" + """Check if a hook entry is from cycode-cli. + + Handles both Cursor format (flat) and Claude Code format (nested). + + Cursor format: {"command": "cycode ai-guardrails scan"} + Claude Code format: {"hooks": [{"type": "command", "command": "cycode ai-guardrails scan --ide claude-code"}]} + """ + # Check Cursor format (flat command) command = entry.get('command', '') - return CYCODE_SCAN_PROMPT_COMMAND in command + if CYCODE_SCAN_PROMPT_COMMAND in command: + return True + + # Check Claude Code format (nested hooks array) + hooks = entry.get('hooks', []) + for hook in hooks: + if isinstance(hook, dict): + hook_command = hook.get('command', '') + if CYCODE_SCAN_PROMPT_COMMAND in hook_command: + return True + + return False def install_hooks( @@ -185,7 +203,15 @@ def get_hooks_status(scope: str = 'user', repo_path: Optional[Path] = None, ide: ide_config = IDE_CONFIGS[ide] has_cycode_hooks = False for event in ide_config.hook_events: - entries = existing.get('hooks', {}).get(event, []) + # Handle event:matcher format + if ':' in event: + actual_event, matcher_prefix = event.split(':', 1) + all_entries = existing.get('hooks', {}).get(actual_event, []) + # Filter entries by matcher + entries = [e for e in all_entries if e.get('matcher', '').startswith(matcher_prefix)] + else: + entries = existing.get('hooks', {}).get(event, []) + cycode_entries = [e for e in entries if is_cycode_hook_entry(e)] if cycode_entries: has_cycode_hooks = True diff --git a/cycode/cli/apps/ai_guardrails/install_command.py b/cycode/cli/apps/ai_guardrails/install_command.py index 6186752d..4b1095ab 100644 --- a/cycode/cli/apps/ai_guardrails/install_command.py +++ b/cycode/cli/apps/ai_guardrails/install_command.py @@ -11,7 +11,7 @@ validate_and_parse_ide, validate_scope, ) -from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS +from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType from cycode.cli.apps.ai_guardrails.hooks_manager import install_hooks from cycode.cli.utils.sentry import add_breadcrumb @@ -30,9 +30,9 @@ def install_command( str, typer.Option( '--ide', - help='IDE to install hooks for (e.g., "cursor"). Defaults to cursor.', + help='IDE to install hooks for (e.g., "cursor", "claude-code", or "all" for all IDEs). Defaults to cursor.', ), - ] = 'cursor', + ] = AIIDEType.CURSOR, repo_path: Annotated[ Optional[Path], typer.Option( @@ -54,6 +54,7 @@ def install_command( cycode ai-guardrails install # Install for all projects (user scope) cycode ai-guardrails install --scope repo # Install for current repo only cycode ai-guardrails install --ide cursor # Install for Cursor IDE + cycode ai-guardrails install --ide all # Install for all supported IDEs cycode ai-guardrails install --scope repo --repo-path /path/to/repo """ add_breadcrumb('ai-guardrails-install') @@ -62,17 +63,35 @@ def install_command( validate_scope(scope) repo_path = resolve_repo_path(scope, repo_path) ide_type = validate_and_parse_ide(ide) - ide_name = IDE_CONFIGS[ide_type].name - success, message = install_hooks(scope, repo_path, ide=ide_type) - if success: - console.print(f'[green]✓[/] {message}') + ides_to_install: list[AIIDEType] = list(AIIDEType) if ide_type is None else [ide_type] + + results: list[tuple[str, bool, str]] = [] + for current_ide in ides_to_install: + ide_name = IDE_CONFIGS[current_ide].name + success, message = install_hooks(scope, repo_path, ide=current_ide) + results.append((ide_name, success, message)) + + # Report results for each IDE + any_success = False + all_success = True + for _ide_name, success, message in results: + if success: + console.print(f'[green]✓[/] {message}') + any_success = True + else: + console.print(f'[red]✗[/] {message}', style='bold red') + all_success = False + + if any_success: console.print() console.print('[bold]Next steps:[/]') - console.print(f'1. Restart {ide_name} to activate the hooks') + successful_ides = [name for name, success, _ in results if success] + ide_list = ', '.join(successful_ides) + console.print(f'1. Restart {ide_list} to activate the hooks') console.print('2. (Optional) Customize policy in ~/.cycode/ai-guardrails.yaml') console.print() console.print('[dim]The hooks will scan prompts, file reads, and MCP tool calls for secrets.[/]') - else: - console.print(f'[red]✗[/] {message}', style='bold red') + + if not all_success: raise typer.Exit(1) diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index 95e9d606..32be1241 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -13,6 +13,7 @@ import typer +from cycode.cli.apps.ai_guardrails.consts import PolicyMode from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value from cycode.cli.apps.ai_guardrails.scan.response_builders import get_response_builder @@ -46,7 +47,7 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli ai_client.create_event(payload, AiHookEventType.PROMPT, AIHookOutcome.ALLOWED) return response_builder.allow_prompt() - mode = get_policy_value(policy, 'mode', default='block') + mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK) prompt = payload.prompt or '' max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000) timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000) @@ -55,29 +56,26 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli scan_id = None block_reason = None outcome = AIHookOutcome.ALLOWED + error_message = None try: violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms) - if ( - violation_summary - and get_policy_value(prompt_config, 'action', default='block') == 'block' - and mode == 'block' - ): - outcome = AIHookOutcome.BLOCKED + if violation_summary: block_reason = BlockReason.SECRETS_IN_PROMPT - user_message = f'{violation_summary}. Remove secrets before sending.' - response = response_builder.deny_prompt(user_message) - else: - if violation_summary: - outcome = AIHookOutcome.WARNED - response = response_builder.allow_prompt() - return response + action = get_policy_value(prompt_config, 'action', default=PolicyMode.BLOCK) + if action == PolicyMode.BLOCK and mode == PolicyMode.BLOCK: + outcome = AIHookOutcome.BLOCKED + user_message = f'{violation_summary}. Remove secrets before sending.' + return response_builder.deny_prompt(user_message) + outcome = AIHookOutcome.WARNED + return response_builder.allow_prompt() except Exception as e: outcome = ( AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED ) - block_reason = BlockReason.SCAN_FAILURE if outcome == AIHookOutcome.BLOCKED else None + block_reason = BlockReason.SCAN_FAILURE + error_message = str(e) raise e finally: ai_client.create_event( @@ -86,6 +84,7 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli outcome, scan_id=scan_id, block_reason=block_reason, + error_message=error_message, ) @@ -106,38 +105,53 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: ai_client.create_event(payload, AiHookEventType.FILE_READ, AIHookOutcome.ALLOWED) return response_builder.allow_permission() - mode = get_policy_value(policy, 'mode', default='block') + mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK) file_path = payload.file_path or '' - action = get_policy_value(file_read_config, 'action', default='block') + action = get_policy_value(file_read_config, 'action', default=PolicyMode.BLOCK) scan_id = None block_reason = None outcome = AIHookOutcome.ALLOWED + error_message = None try: # Check path-based denylist first - if is_denied_path(file_path, policy) and action == 'block': - outcome = AIHookOutcome.BLOCKED + if is_denied_path(file_path, policy): block_reason = BlockReason.SENSITIVE_PATH - user_message = f'Cycode blocked sending {file_path} to the AI (sensitive path policy).' - return response_builder.deny_permission( + if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK: + outcome = AIHookOutcome.BLOCKED + user_message = f'Cycode blocked sending {file_path} to the AI (sensitive path policy).' + return response_builder.deny_permission( + user_message, + 'This file path is classified as sensitive; do not read/send it to the model.', + ) + # Warn mode - ask user for permission + outcome = AIHookOutcome.WARNED + user_message = f'Cycode flagged {file_path} as sensitive. Allow reading?' + return response_builder.ask_permission( user_message, - 'This file path is classified as sensitive; do not read/send it to the model.', + 'This file path is classified as sensitive; proceed with caution.', ) # Scan file content if enabled if get_policy_value(file_read_config, 'scan_content', default=True): violation_summary, scan_id = _scan_path_for_secrets(ctx, file_path, policy) - if violation_summary and action == 'block' and mode == 'block': - outcome = AIHookOutcome.BLOCKED + if violation_summary: block_reason = BlockReason.SECRETS_IN_FILE - user_message = f'Cycode blocked reading {file_path}. {violation_summary}' - return response_builder.deny_permission( + if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK: + outcome = AIHookOutcome.BLOCKED + user_message = f'Cycode blocked reading {file_path}. {violation_summary}' + return response_builder.deny_permission( + user_message, + 'Secrets detected; do not send this file to the model.', + ) + # Warn mode - ask user for permission + outcome = AIHookOutcome.WARNED + user_message = f'Cycode detected secrets in {file_path}. {violation_summary}' + return response_builder.ask_permission( user_message, - 'Secrets detected; do not send this file to the model.', + 'Possible secrets detected; proceed with caution.', ) - if violation_summary: - outcome = AIHookOutcome.WARNED return response_builder.allow_permission() return response_builder.allow_permission() @@ -145,7 +159,8 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: outcome = ( AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED ) - block_reason = BlockReason.SCAN_FAILURE if outcome == AIHookOutcome.BLOCKED else None + block_reason = BlockReason.SCAN_FAILURE + error_message = str(e) raise e finally: ai_client.create_event( @@ -154,6 +169,7 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: outcome, scan_id=scan_id, block_reason=block_reason, + error_message=error_message, ) @@ -175,26 +191,27 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli ai_client.create_event(payload, AiHookEventType.MCP_EXECUTION, AIHookOutcome.ALLOWED) return response_builder.allow_permission() - mode = get_policy_value(policy, 'mode', default='block') + mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK) tool = payload.mcp_tool_name or 'unknown' args = payload.mcp_arguments or {} args_text = args if isinstance(args, str) else json.dumps(args) max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000) timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000) clipped = truncate_utf8(args_text, max_bytes) - action = get_policy_value(mcp_config, 'action', default='block') + action = get_policy_value(mcp_config, 'action', default=PolicyMode.BLOCK) scan_id = None block_reason = None outcome = AIHookOutcome.ALLOWED + error_message = None try: if get_policy_value(mcp_config, 'scan_arguments', default=True): violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms) if violation_summary: - if mode == 'block' and action == 'block': + block_reason = BlockReason.SECRETS_IN_MCP_ARGS + if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK: outcome = AIHookOutcome.BLOCKED - block_reason = BlockReason.SECRETS_IN_MCP_ARGS user_message = f'Cycode blocked MCP tool call "{tool}". {violation_summary}' return response_builder.deny_permission( user_message, @@ -211,7 +228,8 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli outcome = ( AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED ) - block_reason = BlockReason.SCAN_FAILURE if outcome == AIHookOutcome.BLOCKED else None + block_reason = BlockReason.SCAN_FAILURE + error_message = str(e) raise e finally: ai_client.create_event( @@ -220,6 +238,7 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli outcome, scan_id=scan_id, block_reason=block_reason, + error_message=error_message, ) diff --git a/cycode/cli/apps/ai_guardrails/scan/payload.py b/cycode/cli/apps/ai_guardrails/scan/payload.py index 83787348..ce72a574 100644 --- a/cycode/cli/apps/ai_guardrails/scan/payload.py +++ b/cycode/cli/apps/ai_guardrails/scan/payload.py @@ -1,9 +1,120 @@ """Unified payload object for AI hook events from different tools.""" +import json +from collections.abc import Iterator from dataclasses import dataclass +from pathlib import Path from typing import Optional -from cycode.cli.apps.ai_guardrails.scan.types import CURSOR_EVENT_MAPPING +from cycode.cli.apps.ai_guardrails.consts import AIIDEType +from cycode.cli.apps.ai_guardrails.scan.types import ( + CLAUDE_CODE_EVENT_MAPPING, + CLAUDE_CODE_EVENT_NAMES, + CURSOR_EVENT_MAPPING, + CURSOR_EVENT_NAMES, + AiHookEventType, +) + + +def _reverse_readline(path: Path, buf_size: int = 8192) -> Iterator[str]: + """Read a file line by line from the end without loading entire file into memory. + + Yields lines in reverse order (last line first). + """ + with path.open('rb') as f: + f.seek(0, 2) # Seek to end + file_size = f.tell() + if file_size == 0: + return + + remaining = file_size + buffer = b'' + + while remaining > 0: + # Read a chunk from the end + read_size = min(buf_size, remaining) + remaining -= read_size + f.seek(remaining) + chunk = f.read(read_size) + buffer = chunk + buffer + + # Yield complete lines from buffer + while b'\n' in buffer: + # Find the last newline + newline_pos = buffer.rfind(b'\n') + if newline_pos == len(buffer) - 1: + # Trailing newline, look for previous one + newline_pos = buffer.rfind(b'\n', 0, newline_pos) + if newline_pos == -1: + break + # Yield the line after this newline + line = buffer[newline_pos + 1 :] + buffer = buffer[: newline_pos + 1] + if line.strip(): + yield line.decode('utf-8', errors='replace') + + # Yield any remaining content as the first line of the file + if buffer.strip(): + yield buffer.decode('utf-8', errors='replace') + + +def _extract_model(entry: dict) -> Optional[str]: + """Extract model from a transcript entry (top level or nested in message).""" + return entry.get('model') or (entry.get('message') or {}).get('model') + + +def _extract_generation_id(entry: dict) -> Optional[str]: + """Extract generation ID from a user-type transcript entry.""" + if entry.get('type') == 'user': + return entry.get('uuid') + return None + + +def _extract_from_claude_transcript( + transcript_path: str, +) -> tuple[Optional[str], Optional[str], Optional[str]]: + """Extract IDE version, model, and latest generation ID from Claude Code transcript file. + + The transcript is a JSONL file where each line is a JSON object. + We look for 'version' (IDE version), 'model', and 'uuid' (generation ID) fields. + The generation_id is the UUID of the latest 'user' type message. + + Scans from end to start since latest entries are at the end. + Uses reverse reading to avoid loading entire file into memory. + + Returns: + Tuple of (ide_version, model, generation_id), any may be None if not found. + """ + if not transcript_path: + return None, None, None + + path = Path(transcript_path) + if not path.exists(): + return None, None, None + + ide_version = None + model = None + generation_id = None + + try: + for line in _reverse_readline(path): + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + ide_version = ide_version or entry.get('version') + model = model or _extract_model(entry) + generation_id = generation_id or _extract_generation_id(entry) + + if ide_version and model and generation_id: + break + except json.JSONDecodeError: + continue + except OSError: + pass + + return ide_version, model, generation_id @dataclass @@ -18,7 +129,7 @@ class AIHookPayload: # User and IDE information ide_user_email: Optional[str] = None model: Optional[str] = None - ide_provider: str = None # e.g., 'cursor', 'claude-code' + ide_provider: str = None # AIIDEType value (e.g., 'cursor', 'claude-code') ide_version: Optional[str] = None # Event-specific data @@ -44,7 +155,7 @@ def from_cursor_payload(cls, payload: dict) -> 'AIHookPayload': generation_id=payload.get('generation_id'), ide_user_email=payload.get('user_email'), model=payload.get('model'), - ide_provider='cursor', + ide_provider=AIIDEType.CURSOR, ide_version=payload.get('cursor_version'), prompt=payload.get('prompt', ''), file_path=payload.get('file_path') or payload.get('path'), @@ -54,12 +165,95 @@ def from_cursor_payload(cls, payload: dict) -> 'AIHookPayload': ) @classmethod - def from_payload(cls, payload: dict, tool: str = 'cursor') -> 'AIHookPayload': + def from_claude_code_payload(cls, payload: dict) -> 'AIHookPayload': + """Create AIHookPayload from Claude Code IDE payload. + + Claude Code has a different structure: + - hook_event_name: 'UserPromptSubmit' or 'PreToolUse' + - For PreToolUse: tool_name determines if it's file read ('Read') or MCP ('mcp__*') + - tool_input contains tool arguments (e.g., file_path for Read tool) + - transcript_path points to JSONL file with version and model info + """ + hook_event_name = payload.get('hook_event_name', '') + tool_name = payload.get('tool_name', '') + tool_input = payload.get('tool_input') + + if hook_event_name == 'UserPromptSubmit': + canonical_event = AiHookEventType.PROMPT + elif hook_event_name == 'PreToolUse': + canonical_event = AiHookEventType.FILE_READ if tool_name == 'Read' else AiHookEventType.MCP_EXECUTION + else: + # Unknown event, use the raw event name + canonical_event = CLAUDE_CODE_EVENT_MAPPING.get(hook_event_name, hook_event_name) + + # Extract file_path from tool_input for Read tool + file_path = None + if tool_name == 'Read' and isinstance(tool_input, dict): + file_path = tool_input.get('file_path') + + # For MCP tools, the entire tool_input is the arguments + mcp_arguments = tool_input if tool_name.startswith('mcp__') else None + + # Extract MCP server and tool name from tool_name (format: mcp____) + mcp_server_name = None + mcp_tool_name = None + if tool_name.startswith('mcp__'): + parts = tool_name.split('__') + if len(parts) >= 2: + mcp_server_name = parts[1] + if len(parts) >= 3: + mcp_tool_name = parts[2] + + # Extract IDE version, model, and generation ID from transcript file + ide_version, model, generation_id = _extract_from_claude_transcript(payload.get('transcript_path')) + + return cls( + event_name=canonical_event, + conversation_id=payload.get('session_id'), + generation_id=generation_id, + ide_user_email=None, # Claude Code doesn't provide this in hook payload + model=model, + ide_provider=AIIDEType.CLAUDE_CODE, + ide_version=ide_version, + prompt=payload.get('prompt', ''), + file_path=file_path, + mcp_server_name=mcp_server_name, + mcp_tool_name=mcp_tool_name, + mcp_arguments=mcp_arguments, + ) + + @staticmethod + def is_payload_for_ide(payload: dict, ide: str) -> bool: + """Check if the payload's event name matches the expected IDE. + + This prevents double-processing when Cursor reads Claude Code hooks + or vice versa. If the payload's hook_event_name doesn't match the + expected IDE's event names, we should skip processing. + + Args: + payload: The raw payload from the IDE + ide: The IDE name or AIIDEType enum value + + Returns: + True if the payload matches the IDE, False otherwise. + """ + hook_event_name = payload.get('hook_event_name', '') + + if ide == AIIDEType.CLAUDE_CODE: + return hook_event_name in CLAUDE_CODE_EVENT_NAMES + if ide == AIIDEType.CURSOR: + return hook_event_name in CURSOR_EVENT_NAMES + + # Unknown IDE, allow processing + return True + + @classmethod + def from_payload(cls, payload: dict, tool: str = AIIDEType.CURSOR) -> 'AIHookPayload': """Create AIHookPayload from any tool's payload. Args: payload: The raw payload from the IDE - tool: The IDE/tool name (e.g., 'cursor') + tool: The IDE/tool name or AIIDEType enum value Returns: AIHookPayload instance @@ -67,6 +261,8 @@ def from_payload(cls, payload: dict, tool: str = 'cursor') -> 'AIHookPayload': Raises: ValueError: If the tool is not supported """ - if tool == 'cursor': + if tool == AIIDEType.CURSOR: return cls.from_cursor_payload(payload) - raise ValueError(f'Unsupported IDE/tool: {tool}.') + if tool == AIIDEType.CLAUDE_CODE: + return cls.from_claude_code_payload(payload) + raise ValueError(f'Unsupported IDE/tool: {tool}') diff --git a/cycode/cli/apps/ai_guardrails/scan/response_builders.py b/cycode/cli/apps/ai_guardrails/scan/response_builders.py index 867965c3..f0da71b7 100644 --- a/cycode/cli/apps/ai_guardrails/scan/response_builders.py +++ b/cycode/cli/apps/ai_guardrails/scan/response_builders.py @@ -7,6 +7,8 @@ from abc import ABC, abstractmethod +from cycode.cli.apps.ai_guardrails.consts import AIIDEType + class IDEResponseBuilder(ABC): """Abstract base class for IDE-specific response builders.""" @@ -62,17 +64,64 @@ def deny_prompt(self, user_message: str) -> dict: return {'continue': False, 'user_message': user_message} -# Registry of response builders by IDE name +class ClaudeCodeResponseBuilder(IDEResponseBuilder): + """Response builder for Claude Code IDE hooks. + + Claude Code hook response formats: + - UserPromptSubmit: {} for allow, {"decision": "block", "reason": str} for deny + - PreToolUse: hookSpecificOutput with permissionDecision (allow/deny/ask) + """ + + def allow_permission(self) -> dict: + """Allow file read or MCP execution.""" + return { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'allow', + } + } + + def deny_permission(self, user_message: str, agent_message: str) -> dict: + """Deny file read or MCP execution.""" + return { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'deny', + 'permissionDecisionReason': user_message, + } + } + + def ask_permission(self, user_message: str, agent_message: str) -> dict: + """Ask user for permission (warn mode).""" + return { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'ask', + 'permissionDecisionReason': user_message, + } + } + + def allow_prompt(self) -> dict: + """Allow prompt submission (empty response means allow).""" + return {} + + def deny_prompt(self, user_message: str) -> dict: + """Deny prompt submission.""" + return {'decision': 'block', 'reason': user_message} + + +# Registry of response builders by IDE type _RESPONSE_BUILDERS: dict[str, IDEResponseBuilder] = { - 'cursor': CursorResponseBuilder(), + AIIDEType.CURSOR: CursorResponseBuilder(), + AIIDEType.CLAUDE_CODE: ClaudeCodeResponseBuilder(), } -def get_response_builder(ide: str = 'cursor') -> IDEResponseBuilder: +def get_response_builder(ide: str = AIIDEType.CURSOR) -> IDEResponseBuilder: """Get the response builder for a specific IDE. Args: - ide: The IDE name (e.g., 'cursor', 'claude-code') + ide: The IDE name (e.g., 'cursor', 'claude-code') or AIIDEType enum Returns: IDEResponseBuilder instance for the specified IDE @@ -80,7 +129,10 @@ def get_response_builder(ide: str = 'cursor') -> IDEResponseBuilder: Raises: ValueError: If the IDE is not supported """ - builder = _RESPONSE_BUILDERS.get(ide.lower()) + # Normalize to AIIDEType if string passed + if isinstance(ide, str): + ide = ide.lower() + builder = _RESPONSE_BUILDERS.get(ide) if not builder: raise ValueError(f'Unsupported IDE: {ide}. Supported IDEs: {list(_RESPONSE_BUILDERS.keys())}') return builder diff --git a/cycode/cli/apps/ai_guardrails/scan/scan_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py index e08bb4de..73981831 100644 --- a/cycode/cli/apps/ai_guardrails/scan/scan_command.py +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -16,6 +16,7 @@ import click import typer +from cycode.cli.apps.ai_guardrails.consts import AIIDEType from cycode.cli.apps.ai_guardrails.scan.handlers import get_handler_for_event from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.policy import load_policy @@ -69,7 +70,7 @@ def scan_command( help='IDE that sent the payload (e.g., "cursor"). Defaults to cursor.', hidden=True, ), - ] = 'cursor', + ] = AIIDEType.CURSOR, ) -> None: """Scan content from AI IDE hooks for secrets. @@ -96,6 +97,16 @@ def scan_command( output_json(response_builder.allow_prompt()) return + # Check if the payload matches the expected IDE - prevents double-processing + # when Cursor reads Claude Code hooks from ~/.claude/settings.json + if not AIHookPayload.is_payload_for_ide(payload, tool): + logger.debug( + 'Payload event does not match expected IDE, skipping', + extra={'hook_event_name': payload.get('hook_event_name'), 'expected_ide': tool}, + ) + output_json(response_builder.allow_prompt()) + return + unified_payload = AIHookPayload.from_payload(payload, tool=tool) event_name = unified_payload.event_name logger.debug('Processing AI guardrails hook', extra={'event_name': event_name, 'tool': tool}) diff --git a/cycode/cli/apps/ai_guardrails/scan/types.py b/cycode/cli/apps/ai_guardrails/scan/types.py index 095ca61b..585c7820 100644 --- a/cycode/cli/apps/ai_guardrails/scan/types.py +++ b/cycode/cli/apps/ai_guardrails/scan/types.py @@ -31,6 +31,17 @@ class AiHookEventType(StrEnum): 'beforeMCPExecution': AiHookEventType.MCP_EXECUTION, } +# Claude Code event mapping - note that PreToolUse requires tool_name inspection +# to determine the actual event type (file read vs MCP execution) +CLAUDE_CODE_EVENT_MAPPING = { + 'UserPromptSubmit': AiHookEventType.PROMPT, + 'PreToolUse': None, # Requires tool_name inspection to determine actual type +} + +# Set of known event names per IDE (for IDE detection) +CURSOR_EVENT_NAMES = set(CURSOR_EVENT_MAPPING.keys()) +CLAUDE_CODE_EVENT_NAMES = set(CLAUDE_CODE_EVENT_MAPPING.keys()) + class AIHookOutcome(StrEnum): """Outcome of an AI hook event evaluation.""" diff --git a/cycode/cli/apps/ai_guardrails/status_command.py b/cycode/cli/apps/ai_guardrails/status_command.py index 0a9801b5..14a31e7f 100644 --- a/cycode/cli/apps/ai_guardrails/status_command.py +++ b/cycode/cli/apps/ai_guardrails/status_command.py @@ -8,6 +8,7 @@ from rich.table import Table from cycode.cli.apps.ai_guardrails.command_utils import console, validate_and_parse_ide, validate_scope +from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType from cycode.cli.apps.ai_guardrails.hooks_manager import get_hooks_status from cycode.cli.utils.sentry import add_breadcrumb @@ -26,9 +27,9 @@ def status_command( str, typer.Option( '--ide', - help='IDE to check status for (e.g., "cursor"). Defaults to cursor.', + help='IDE to check status for (e.g., "cursor", "claude-code", or "all" for all IDEs). Defaults to cursor.', ), - ] = 'cursor', + ] = AIIDEType.CURSOR, repo_path: Annotated[ Optional[Path], typer.Option( @@ -50,6 +51,7 @@ def status_command( cycode ai-guardrails status --scope user # Show only user-level status cycode ai-guardrails status --scope repo # Show only repo-level status cycode ai-guardrails status --ide cursor # Check status for Cursor IDE + cycode ai-guardrails status --ide all # Check status for all supported IDEs """ add_breadcrumb('ai-guardrails-status') @@ -59,34 +61,41 @@ def status_command( repo_path = Path(os.getcwd()) ide_type = validate_and_parse_ide(ide) - scopes_to_check = ['user', 'repo'] if scope == 'all' else [scope] + ides_to_check: list[AIIDEType] = list(AIIDEType) if ide_type is None else [ide_type] - for check_scope in scopes_to_check: - status = get_hooks_status(check_scope, repo_path if check_scope == 'repo' else None, ide=ide_type) + scopes_to_check = ['user', 'repo'] if scope == 'all' else [scope] + for current_ide in ides_to_check: + ide_name = IDE_CONFIGS[current_ide].name console.print() - console.print(f'[bold]{check_scope.upper()} SCOPE[/]') - console.print(f'Path: {status["hooks_path"]}') + console.print(f'[bold cyan]═══ {ide_name} ═══[/]') + + for check_scope in scopes_to_check: + status = get_hooks_status(check_scope, repo_path if check_scope == 'repo' else None, ide=current_ide) + + console.print() + console.print(f'[bold]{check_scope.upper()} SCOPE[/]') + console.print(f'Path: {status["hooks_path"]}') - if not status['file_exists']: - console.print('[dim]No hooks.json file found[/]') - continue + if not status['file_exists']: + console.print('[dim]No hooks file found[/]') + continue - if status['cycode_installed']: - console.print('[green]✓ Cycode AI guardrails: INSTALLED[/]') - else: - console.print('[yellow]○ Cycode AI guardrails: NOT INSTALLED[/]') + if status['cycode_installed']: + console.print('[green]✓ Cycode AI guardrails: INSTALLED[/]') + else: + console.print('[yellow]○ Cycode AI guardrails: NOT INSTALLED[/]') - # Show hook details - table = Table(show_header=True, header_style='bold') - table.add_column('Hook Event') - table.add_column('Cycode Enabled') - table.add_column('Total Hooks') + # Show hook details + table = Table(show_header=True, header_style='bold') + table.add_column('Hook Event') + table.add_column('Cycode Enabled') + table.add_column('Total Hooks') - for event, info in status['hooks'].items(): - enabled = '[green]Yes[/]' if info['enabled'] else '[dim]No[/]' - table.add_row(event, enabled, str(info['total_entries'])) + for event, info in status['hooks'].items(): + enabled = '[green]Yes[/]' if info['enabled'] else '[dim]No[/]' + table.add_row(event, enabled, str(info['total_entries'])) - console.print(table) + console.print(table) console.print() diff --git a/cycode/cli/apps/ai_guardrails/uninstall_command.py b/cycode/cli/apps/ai_guardrails/uninstall_command.py index 23315693..acf3d0c7 100644 --- a/cycode/cli/apps/ai_guardrails/uninstall_command.py +++ b/cycode/cli/apps/ai_guardrails/uninstall_command.py @@ -11,7 +11,7 @@ validate_and_parse_ide, validate_scope, ) -from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS +from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType from cycode.cli.apps.ai_guardrails.hooks_manager import uninstall_hooks from cycode.cli.utils.sentry import add_breadcrumb @@ -30,9 +30,9 @@ def uninstall_command( str, typer.Option( '--ide', - help='IDE to uninstall hooks from (e.g., "cursor"). Defaults to cursor.', + help='IDE to uninstall hooks from (e.g., "cursor", "claude-code", "all"). Defaults to cursor.', ), - ] = 'cursor', + ] = AIIDEType.CURSOR, repo_path: Annotated[ Optional[Path], typer.Option( @@ -54,6 +54,7 @@ def uninstall_command( cycode ai-guardrails uninstall # Remove user-level hooks cycode ai-guardrails uninstall --scope repo # Remove repo-level hooks cycode ai-guardrails uninstall --ide cursor # Uninstall from Cursor IDE + cycode ai-guardrails uninstall --ide all # Uninstall from all supported IDEs """ add_breadcrumb('ai-guardrails-uninstall') @@ -61,13 +62,31 @@ def uninstall_command( validate_scope(scope) repo_path = resolve_repo_path(scope, repo_path) ide_type = validate_and_parse_ide(ide) - ide_name = IDE_CONFIGS[ide_type].name - success, message = uninstall_hooks(scope, repo_path, ide=ide_type) - if success: - console.print(f'[green]✓[/] {message}') + ides_to_uninstall: list[AIIDEType] = list(AIIDEType) if ide_type is None else [ide_type] + + results: list[tuple[str, bool, str]] = [] + for current_ide in ides_to_uninstall: + ide_name = IDE_CONFIGS[current_ide].name + success, message = uninstall_hooks(scope, repo_path, ide=current_ide) + results.append((ide_name, success, message)) + + # Report results for each IDE + any_success = False + all_success = True + for _ide_name, success, message in results: + if success: + console.print(f'[green]✓[/] {message}') + any_success = True + else: + console.print(f'[red]✗[/] {message}', style='bold red') + all_success = False + + if any_success: console.print() - console.print(f'[dim]Restart {ide_name} for changes to take effect.[/]') - else: - console.print(f'[red]✗[/] {message}', style='bold red') + successful_ides = [name for name, success, _ in results if success] + ide_list = ', '.join(successful_ides) + console.print(f'[dim]Restart {ide_list} for changes to take effect.[/]') + + if not all_success: raise typer.Exit(1) diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py index 627e2b33..1090ad8d 100644 --- a/cycode/cyclient/ai_security_manager_client.py +++ b/cycode/cyclient/ai_security_manager_client.py @@ -61,6 +61,7 @@ def create_event( outcome: 'AIHookOutcome', scan_id: Optional[str] = None, block_reason: Optional['BlockReason'] = None, + error_message: Optional[str] = None, ) -> None: """Create an AI hook event from hook payload.""" conversation_id = payload.conversation_id @@ -77,6 +78,7 @@ def create_event( 'cli_scan_id': scan_id, 'mcp_server_name': payload.mcp_server_name, 'mcp_tool_name': payload.mcp_tool_name, + 'error_message': error_message, } try: diff --git a/tests/cli/commands/ai_guardrails/scan/test_handlers.py b/tests/cli/commands/ai_guardrails/scan/test_handlers.py index 58dfe195..634469b7 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_handlers.py +++ b/tests/cli/commands/ai_guardrails/scan/test_handlers.py @@ -135,8 +135,8 @@ def test_handle_before_submit_prompt_scan_failure_fail_open( mock_ctx.obj['ai_security_client'].create_event.assert_called_once() call_args = mock_ctx.obj['ai_security_client'].create_event.call_args assert call_args.args[2] == AIHookOutcome.ALLOWED - # When fail_open=True, no block_reason since action is allowed - assert call_args.kwargs['block_reason'] is None + # block_reason is set for tracking even when fail_open allows the action + assert call_args.kwargs['block_reason'] == BlockReason.SCAN_FAILURE @patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') diff --git a/tests/cli/commands/ai_guardrails/scan/test_payload.py b/tests/cli/commands/ai_guardrails/scan/test_payload.py index 9d14dda3..27c3010f 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_payload.py +++ b/tests/cli/commands/ai_guardrails/scan/test_payload.py @@ -1,6 +1,7 @@ """Tests for AI hook payload normalization.""" import pytest +from pytest_mock import MockerFixture from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType @@ -133,3 +134,243 @@ def test_from_cursor_payload_empty_fields() -> None: assert unified.conversation_id is None assert unified.prompt == '' # Default to empty string assert unified.ide_provider == 'cursor' + + +# Claude Code payload tests + + +def test_from_claude_code_payload_prompt_event() -> None: + """Test conversion of Claude Code UserPromptSubmit payload.""" + claude_payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'Test prompt for Claude Code', + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id == 'session-123' + assert unified.ide_provider == 'claude-code' + assert unified.prompt == 'Test prompt for Claude Code' + + +def test_from_claude_code_payload_file_read_event() -> None: + """Test conversion of Claude Code PreToolUse with Read tool.""" + claude_payload = { + 'hook_event_name': 'PreToolUse', + 'session_id': 'session-456', + 'tool_name': 'Read', + 'tool_input': {'file_path': '/path/to/secret.env'}, + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + + assert unified.event_name == AiHookEventType.FILE_READ + assert unified.file_path == '/path/to/secret.env' + assert unified.ide_provider == 'claude-code' + assert unified.mcp_tool_name is None + + +def test_from_claude_code_payload_mcp_execution_event() -> None: + """Test conversion of Claude Code PreToolUse with MCP tool.""" + claude_payload = { + 'hook_event_name': 'PreToolUse', + 'session_id': 'session-789', + 'tool_name': 'mcp__gitlab__discussion_list', + 'tool_input': {'resource_type': 'merge_request', 'parent_id': 'org/repo', 'resource_id': '4'}, + } + + unified = AIHookPayload.from_payload(claude_payload, tool='claude-code') + + assert unified.event_name == AiHookEventType.MCP_EXECUTION + assert unified.mcp_server_name == 'gitlab' + assert unified.mcp_tool_name == 'discussion_list' + assert unified.mcp_arguments == {'resource_type': 'merge_request', 'parent_id': 'org/repo', 'resource_id': '4'} + assert unified.ide_provider == 'claude-code' + + +def test_from_claude_code_payload_empty_fields() -> None: + """Test handling of empty/missing fields for Claude Code.""" + claude_payload = { + 'hook_event_name': 'UserPromptSubmit', + # Most fields missing + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id is None + assert unified.prompt == '' # Default to empty string + assert unified.ide_provider == 'claude-code' + + +# Claude Code transcript extraction tests + + +def test_from_claude_code_payload_extracts_from_transcript(mocker: MockerFixture) -> None: + """Test that version, model, and generation_id are extracted from transcript file.""" + transcript_content = ( + b'{"type":"user","version":"2.1.20","uuid":"user-uuid-1","message":{"role":"user","content":"hello"}}\n' + b'{"type":"assistant","message":{"model":"claude-opus-4-5-20251101","role":"assistant",' + b'"content":[{"type":"text","text":"Hi!"}]},"uuid":"assistant-uuid-1"}\n' + b'{"type":"user","version":"2.1.20","uuid":"user-uuid-2","message":{"role":"user","content":"test prompt"}}\n' + ) + mock_path = mocker.patch('cycode.cli.apps.ai_guardrails.scan.payload.Path') + mock_path.return_value.exists.return_value = True + mock_path.return_value.open.return_value.__enter__.return_value.seek = mocker.Mock() + mock_path.return_value.open.return_value.__enter__.return_value.tell.return_value = len(transcript_content) + mock_path.return_value.open.return_value.__enter__.return_value.read.return_value = transcript_content + + claude_payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test prompt', + 'transcript_path': '/mock/transcript.jsonl', + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + + assert unified.ide_version == '2.1.20' + assert unified.model == 'claude-opus-4-5-20251101' + assert unified.generation_id == 'user-uuid-2' + + +def test_from_claude_code_payload_handles_missing_transcript(mocker: MockerFixture) -> None: + """Test that missing transcript file doesn't break payload parsing.""" + mock_path = mocker.patch('cycode.cli.apps.ai_guardrails.scan.payload.Path') + mock_path.return_value.exists.return_value = False + + claude_payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test', + 'transcript_path': '/nonexistent/path/transcript.jsonl', + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + + assert unified.ide_version is None + assert unified.model is None + assert unified.generation_id is None + assert unified.conversation_id == 'session-123' + assert unified.prompt == 'test' + + +def test_from_claude_code_payload_handles_no_transcript_path() -> None: + """Test that absent transcript_path doesn't break payload parsing.""" + claude_payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test', + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + + assert unified.ide_version is None + assert unified.model is None + assert unified.generation_id is None + + +def test_from_claude_code_payload_extracts_model_from_nested_message(mocker: MockerFixture) -> None: + """Test that model is extracted from nested message.model field.""" + transcript_content = ( + b'{"type":"assistant","message":{"model":"claude-sonnet-4-20250514",' + b'"role":"assistant","content":[]},"uuid":"uuid-1"}\n' + ) + + mock_path = mocker.patch('cycode.cli.apps.ai_guardrails.scan.payload.Path') + mock_path.return_value.exists.return_value = True + mock_path.return_value.open.return_value.__enter__.return_value.seek = mocker.Mock() + mock_path.return_value.open.return_value.__enter__.return_value.tell.return_value = len(transcript_content) + mock_path.return_value.open.return_value.__enter__.return_value.read.return_value = transcript_content + + claude_payload = { + 'hook_event_name': 'UserPromptSubmit', + 'prompt': 'test', + 'transcript_path': '/mock/transcript.jsonl', + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + + assert unified.model == 'claude-sonnet-4-20250514' + + +def test_from_claude_code_payload_gets_latest_user_uuid(mocker: MockerFixture) -> None: + """Test that generation_id is the UUID of the latest user message.""" + transcript_content = b"""{"type":"user","uuid":"old-user-uuid","message":{"role":"user","content":"first"}} +{"type":"assistant","uuid":"assistant-uuid","message":{"role":"assistant","content":[]}} +{"type":"user","uuid":"latest-user-uuid","message":{"role":"user","content":"second"}} +{"type":"assistant","uuid":"last-assistant-uuid","message":{"role":"assistant","content":[]}} +""" + mock_path = mocker.patch('cycode.cli.apps.ai_guardrails.scan.payload.Path') + mock_path.return_value.exists.return_value = True + mock_path.return_value.open.return_value.__enter__.return_value.seek = mocker.Mock() + mock_path.return_value.open.return_value.__enter__.return_value.tell.return_value = len(transcript_content) + mock_path.return_value.open.return_value.__enter__.return_value.read.return_value = transcript_content + + claude_payload = { + 'hook_event_name': 'UserPromptSubmit', + 'prompt': 'test', + 'transcript_path': '/mock/transcript.jsonl', + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + + assert unified.generation_id == 'latest-user-uuid' + + +# IDE detection tests + + +def test_is_payload_for_ide_claude_code_matches_claude_code() -> None: + """Test that Claude Code events match when expected IDE is claude-code.""" + payload = {'hook_event_name': 'UserPromptSubmit'} + assert AIHookPayload.is_payload_for_ide(payload, 'claude-code') is True + + payload = {'hook_event_name': 'PreToolUse'} + assert AIHookPayload.is_payload_for_ide(payload, 'claude-code') is True + + +def test_is_payload_for_ide_cursor_matches_cursor() -> None: + """Test that Cursor events match when expected IDE is cursor.""" + payload = {'hook_event_name': 'beforeSubmitPrompt'} + assert AIHookPayload.is_payload_for_ide(payload, 'cursor') is True + + payload = {'hook_event_name': 'beforeReadFile'} + assert AIHookPayload.is_payload_for_ide(payload, 'cursor') is True + + payload = {'hook_event_name': 'beforeMCPExecution'} + assert AIHookPayload.is_payload_for_ide(payload, 'cursor') is True + + +def test_is_payload_for_ide_claude_code_does_not_match_cursor() -> None: + """Test that Claude Code events don't match when expected IDE is cursor. + + This prevents double-processing when Cursor reads Claude Code hooks. + """ + payload = {'hook_event_name': 'UserPromptSubmit'} + assert AIHookPayload.is_payload_for_ide(payload, 'cursor') is False + + payload = {'hook_event_name': 'PreToolUse'} + assert AIHookPayload.is_payload_for_ide(payload, 'cursor') is False + + +def test_is_payload_for_ide_cursor_does_not_match_claude_code() -> None: + """Test that Cursor events don't match when expected IDE is claude-code.""" + payload = {'hook_event_name': 'beforeSubmitPrompt'} + assert AIHookPayload.is_payload_for_ide(payload, 'claude-code') is False + + payload = {'hook_event_name': 'beforeReadFile'} + assert AIHookPayload.is_payload_for_ide(payload, 'claude-code') is False + + +def test_is_payload_for_ide_empty_event_name() -> None: + """Test handling of empty or missing hook_event_name.""" + payload = {'hook_event_name': ''} + assert AIHookPayload.is_payload_for_ide(payload, 'cursor') is False + assert AIHookPayload.is_payload_for_ide(payload, 'claude-code') is False + + payload = {} + assert AIHookPayload.is_payload_for_ide(payload, 'cursor') is False + assert AIHookPayload.is_payload_for_ide(payload, 'claude-code') is False diff --git a/tests/cli/commands/ai_guardrails/scan/test_response_builders.py b/tests/cli/commands/ai_guardrails/scan/test_response_builders.py index 86e87ca7..45f80829 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_response_builders.py +++ b/tests/cli/commands/ai_guardrails/scan/test_response_builders.py @@ -3,6 +3,7 @@ import pytest from cycode.cli.apps.ai_guardrails.scan.response_builders import ( + ClaudeCodeResponseBuilder, CursorResponseBuilder, IDEResponseBuilder, get_response_builder, @@ -77,3 +78,71 @@ def test_cursor_response_builder_is_singleton() -> None: builder2 = get_response_builder('cursor') assert builder1 is builder2 + + +# Claude Code response builder tests + + +def test_claude_code_response_builder_allow_permission() -> None: + """Test Claude Code allow permission response.""" + builder = ClaudeCodeResponseBuilder() + response = builder.allow_permission() + + assert response == { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'allow', + } + } + + +def test_claude_code_response_builder_deny_permission() -> None: + """Test Claude Code deny permission response with messages.""" + builder = ClaudeCodeResponseBuilder() + response = builder.deny_permission('User message', 'Agent message') + + assert response == { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'deny', + 'permissionDecisionReason': 'User message', + } + } + + +def test_claude_code_response_builder_ask_permission() -> None: + """Test Claude Code ask permission response for warnings.""" + builder = ClaudeCodeResponseBuilder() + response = builder.ask_permission('Warning message', 'Agent warning') + + assert response == { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'ask', + 'permissionDecisionReason': 'Warning message', + } + } + + +def test_claude_code_response_builder_allow_prompt() -> None: + """Test Claude Code allow prompt response (empty dict).""" + builder = ClaudeCodeResponseBuilder() + response = builder.allow_prompt() + + assert response == {} + + +def test_claude_code_response_builder_deny_prompt() -> None: + """Test Claude Code deny prompt response with message.""" + builder = ClaudeCodeResponseBuilder() + response = builder.deny_prompt('Secrets detected') + + assert response == {'decision': 'block', 'reason': 'Secrets detected'} + + +def test_get_response_builder_claude_code() -> None: + """Test getting Claude Code response builder.""" + builder = get_response_builder('claude-code') + + assert isinstance(builder, ClaudeCodeResponseBuilder) + assert isinstance(builder, IDEResponseBuilder) diff --git a/tests/cli/commands/ai_guardrails/scan/test_scan_command.py b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py new file mode 100644 index 00000000..d1473c69 --- /dev/null +++ b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py @@ -0,0 +1,138 @@ +"""Tests for AI guardrails scan command.""" + +import json +from io import StringIO +from unittest.mock import MagicMock + +import pytest +from pytest_mock import MockerFixture + +from cycode.cli.apps.ai_guardrails.scan.scan_command import scan_command + + +@pytest.fixture +def mock_ctx() -> MagicMock: + """Create a mock typer context.""" + ctx = MagicMock() + ctx.obj = {} + return ctx + + +@pytest.fixture +def mock_scan_command_deps(mocker: MockerFixture) -> dict[str, MagicMock]: + """Mock scan_command dependencies that should not be called on early exit.""" + return { + 'initialize_clients': mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command._initialize_clients'), + 'load_policy': mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command.load_policy'), + 'get_handler': mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command.get_handler_for_event'), + } + + +def _assert_no_api_calls(mocks: dict[str, MagicMock]) -> None: + """Assert that no API-related functions were called.""" + mocks['initialize_clients'].assert_not_called() + mocks['load_policy'].assert_not_called() + mocks['get_handler'].assert_not_called() + + +class TestIdeMismatchSkipsProcessing: + """Tests that verify IDE mismatch causes early exit without API calls.""" + + def test_claude_code_payload_with_cursor_ide( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Test Claude Code payload is skipped when --ide cursor is specified. + + When Cursor reads Claude Code hooks from ~/.claude/settings.json, it will invoke + the hook with Claude Code event names. The scan command should skip processing. + """ + payload = {'hook_event_name': 'UserPromptSubmit', 'session_id': 'session-123', 'prompt': 'test'} + mocker.patch('sys.stdin', StringIO(json.dumps(payload))) + + scan_command(mock_ctx, ide='cursor') + + _assert_no_api_calls(mock_scan_command_deps) + response = json.loads(capsys.readouterr().out) + assert response.get('continue') is True + + def test_cursor_payload_with_claude_code_ide( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Test Cursor payload is skipped when --ide claude-code is specified.""" + payload = {'hook_event_name': 'beforeSubmitPrompt', 'conversation_id': 'conv-123', 'prompt': 'test'} + mocker.patch('sys.stdin', StringIO(json.dumps(payload))) + + scan_command(mock_ctx, ide='claude-code') + + _assert_no_api_calls(mock_scan_command_deps) + response = json.loads(capsys.readouterr().out) + assert response == {} # Claude Code allow_prompt returns empty dict + + +class TestInvalidPayloadSkipsProcessing: + """Tests that verify invalid payloads cause early exit without API calls.""" + + def test_empty_payload( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Test empty payload skips processing.""" + mocker.patch('sys.stdin', StringIO('')) + + scan_command(mock_ctx, ide='cursor') + + mock_scan_command_deps['initialize_clients'].assert_not_called() + response = json.loads(capsys.readouterr().out) + assert response.get('continue') is True + + def test_invalid_json_payload( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Test invalid JSON skips processing.""" + mocker.patch('sys.stdin', StringIO('not valid json {')) + + scan_command(mock_ctx, ide='cursor') + + mock_scan_command_deps['initialize_clients'].assert_not_called() + response = json.loads(capsys.readouterr().out) + assert response.get('continue') is True + + +class TestMatchingIdeProcessesPayload: + """Tests that verify matching IDE processes the payload normally.""" + + def test_claude_code_payload_with_claude_code_ide( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Test Claude Code payload is processed when --ide claude-code is specified.""" + payload = {'hook_event_name': 'UserPromptSubmit', 'session_id': 'session-123', 'prompt': 'test'} + mocker.patch('sys.stdin', StringIO(json.dumps(payload))) + + mock_scan_command_deps['load_policy'].return_value = {'fail_open': True} + mock_handler = MagicMock(return_value={'decision': 'allow'}) + mock_scan_command_deps['get_handler'].return_value = mock_handler + + scan_command(mock_ctx, ide='claude-code') + + mock_scan_command_deps['initialize_clients'].assert_called_once() + mock_scan_command_deps['load_policy'].assert_called_once() + mock_scan_command_deps['get_handler'].assert_called_once() + mock_handler.assert_called_once() diff --git a/tests/cli/commands/ai_guardrails/test_command_utils.py b/tests/cli/commands/ai_guardrails/test_command_utils.py index 4f0ef55e..5d8d224b 100644 --- a/tests/cli/commands/ai_guardrails/test_command_utils.py +++ b/tests/cli/commands/ai_guardrails/test_command_utils.py @@ -15,6 +15,9 @@ def test_validate_and_parse_ide_valid() -> None: assert validate_and_parse_ide('cursor') == AIIDEType.CURSOR assert validate_and_parse_ide('CURSOR') == AIIDEType.CURSOR assert validate_and_parse_ide('CuRsOr') == AIIDEType.CURSOR + assert validate_and_parse_ide('claude-code') == AIIDEType.CLAUDE_CODE + assert validate_and_parse_ide('Claude-Code') == AIIDEType.CLAUDE_CODE + assert validate_and_parse_ide('all') is None def test_validate_and_parse_ide_invalid() -> None: diff --git a/tests/cli/commands/ai_guardrails/test_hooks_manager.py b/tests/cli/commands/ai_guardrails/test_hooks_manager.py new file mode 100644 index 00000000..f0dec6f7 --- /dev/null +++ b/tests/cli/commands/ai_guardrails/test_hooks_manager.py @@ -0,0 +1,53 @@ +"""Tests for AI guardrails hooks manager.""" + +from cycode.cli.apps.ai_guardrails.hooks_manager import is_cycode_hook_entry + + +def test_is_cycode_hook_entry_cursor_format() -> None: + """Test detecting Cycode hook in Cursor format (flat command).""" + entry = {'command': 'cycode ai-guardrails scan'} + assert is_cycode_hook_entry(entry) is True + + entry = {'command': 'cycode ai-guardrails scan --some-flag'} + assert is_cycode_hook_entry(entry) is True + + +def test_is_cycode_hook_entry_claude_code_format() -> None: + """Test detecting Cycode hook in Claude Code format (nested).""" + entry = { + 'hooks': [{'type': 'command', 'command': 'cycode ai-guardrails scan --ide claude-code'}], + } + assert is_cycode_hook_entry(entry) is True + + entry = { + 'matcher': 'Read', + 'hooks': [{'type': 'command', 'command': 'cycode ai-guardrails scan --ide claude-code'}], + } + assert is_cycode_hook_entry(entry) is True + + +def test_is_cycode_hook_entry_non_cycode() -> None: + """Test that non-Cycode hooks are not detected.""" + # Cursor format + entry = {'command': 'some-other-command'} + assert is_cycode_hook_entry(entry) is False + + # Claude Code format + entry = { + 'hooks': [{'type': 'command', 'command': 'some-other-command'}], + } + assert is_cycode_hook_entry(entry) is False + + # Empty entry + entry = {} + assert is_cycode_hook_entry(entry) is False + + +def test_is_cycode_hook_entry_partial_match() -> None: + """Test partial command match.""" + # Should match if command contains 'cycode ai-guardrails scan' + entry = {'command': '/usr/local/bin/cycode ai-guardrails scan'} + assert is_cycode_hook_entry(entry) is True + + entry = {'command': 'cycode ai-guardrails scan --verbose'} + assert is_cycode_hook_entry(entry) is True From 71d08c86cb7e4bcf36142252fc07e1557ef679f8 Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Wed, 4 Feb 2026 15:46:24 +0000 Subject: [PATCH 229/257] CM-58972: Remove sentry (#380) --- cycode/cli/app.py | 4 - .../cli/apps/ai_guardrails/install_command.py | 3 - .../apps/ai_guardrails/scan/scan_command.py | 3 - .../cli/apps/ai_guardrails/status_command.py | 3 - .../apps/ai_guardrails/uninstall_command.py | 3 - cycode/cli/apps/auth/auth_command.py | 2 - .../cli/apps/configure/configure_command.py | 3 - cycode/cli/apps/ignore/ignore_command.py | 3 - cycode/cli/apps/mcp/mcp_command.py | 3 - cycode/cli/apps/report/report_command.py | 2 - .../cli/apps/report/sbom/path/path_command.py | 3 - .../repository_url/repository_url_command.py | 3 - cycode/cli/apps/report/sbom/sbom_command.py | 3 - .../report_import/report_import_command.py | 3 - .../apps/report_import/sbom/sbom_command.py | 3 - .../commit_history/commit_history_command.py | 3 - cycode/cli/apps/scan/path/path_command.py | 3 - .../scan/pre_commit/pre_commit_command.py | 3 - .../apps/scan/pre_push/pre_push_command.py | 3 - .../scan/pre_receive/pre_receive_command.py | 3 - .../scan/repository/repository_command.py | 3 - .../cli/apps/scan/scan_ci/scan_ci_command.py | 2 - cycode/cli/apps/scan/scan_command.py | 4 - cycode/cli/consts.py | 8 -- cycode/cli/exceptions/handle_errors.py | 3 - .../cli/user_settings/credentials_manager.py | 5 - cycode/cli/utils/sentry.py | 112 ------------------ cycode/cyclient/headers.py | 3 - poetry.lock | 64 +--------- pyproject.toml | 1 - 30 files changed, 2 insertions(+), 262 deletions(-) delete mode 100644 cycode/cli/utils/sentry.py diff --git a/cycode/cli/app.py b/cycode/cli/app.py index e838519e..41391f99 100644 --- a/cycode/cli/app.py +++ b/cycode/cli/app.py @@ -19,7 +19,6 @@ from cycode.cli.printers import ConsolePrinter from cycode.cli.user_settings.configuration_manager import ConfigurationManager from cycode.cli.utils.progress_bar import SCAN_PROGRESS_BAR_SECTIONS, get_progress_bar -from cycode.cli.utils.sentry import add_breadcrumb, init_sentry from cycode.cli.utils.version_checker import version_checker from cycode.cyclient.cycode_client_base import CycodeClientBase from cycode.cyclient.models import UserAgentOptionScheme @@ -143,9 +142,6 @@ def app_callback( ] = None, ) -> None: """[bold cyan]Cycode CLI - Command Line Interface for Cycode.[/]""" - init_sentry() - add_breadcrumb('cycode') - ctx.ensure_object(dict) configuration_manager = ConfigurationManager() diff --git a/cycode/cli/apps/ai_guardrails/install_command.py b/cycode/cli/apps/ai_guardrails/install_command.py index 4b1095ab..882ebad4 100644 --- a/cycode/cli/apps/ai_guardrails/install_command.py +++ b/cycode/cli/apps/ai_guardrails/install_command.py @@ -13,7 +13,6 @@ ) from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType from cycode.cli.apps.ai_guardrails.hooks_manager import install_hooks -from cycode.cli.utils.sentry import add_breadcrumb def install_command( @@ -57,8 +56,6 @@ def install_command( cycode ai-guardrails install --ide all # Install for all supported IDEs cycode ai-guardrails install --scope repo --repo-path /path/to/repo """ - add_breadcrumb('ai-guardrails-install') - # Validate inputs validate_scope(scope) repo_path = resolve_repo_path(scope, repo_path) diff --git a/cycode/cli/apps/ai_guardrails/scan/scan_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py index 73981831..288b0025 100644 --- a/cycode/cli/apps/ai_guardrails/scan/scan_command.py +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -25,7 +25,6 @@ from cycode.cli.apps.ai_guardrails.scan.utils import output_json, safe_json_parse from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError from cycode.cli.utils.get_api_client import get_ai_security_manager_client, get_scan_cycode_client -from cycode.cli.utils.sentry import add_breadcrumb from cycode.logger import get_logger logger = get_logger('AI Guardrails') @@ -84,8 +83,6 @@ def scan_command( Example usage (from IDE hooks configuration): { "command": "cycode ai-guardrails scan" } """ - add_breadcrumb('ai-guardrails-scan') - stdin_data = sys.stdin.read().strip() payload = safe_json_parse(stdin_data) diff --git a/cycode/cli/apps/ai_guardrails/status_command.py b/cycode/cli/apps/ai_guardrails/status_command.py index 14a31e7f..0808d806 100644 --- a/cycode/cli/apps/ai_guardrails/status_command.py +++ b/cycode/cli/apps/ai_guardrails/status_command.py @@ -10,7 +10,6 @@ from cycode.cli.apps.ai_guardrails.command_utils import console, validate_and_parse_ide, validate_scope from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType from cycode.cli.apps.ai_guardrails.hooks_manager import get_hooks_status -from cycode.cli.utils.sentry import add_breadcrumb def status_command( @@ -53,8 +52,6 @@ def status_command( cycode ai-guardrails status --ide cursor # Check status for Cursor IDE cycode ai-guardrails status --ide all # Check status for all supported IDEs """ - add_breadcrumb('ai-guardrails-status') - # Validate inputs (status allows 'all' scope) validate_scope(scope, allowed_scopes=('user', 'repo', 'all')) if repo_path is None: diff --git a/cycode/cli/apps/ai_guardrails/uninstall_command.py b/cycode/cli/apps/ai_guardrails/uninstall_command.py index acf3d0c7..be4288e3 100644 --- a/cycode/cli/apps/ai_guardrails/uninstall_command.py +++ b/cycode/cli/apps/ai_guardrails/uninstall_command.py @@ -13,7 +13,6 @@ ) from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType from cycode.cli.apps.ai_guardrails.hooks_manager import uninstall_hooks -from cycode.cli.utils.sentry import add_breadcrumb def uninstall_command( @@ -56,8 +55,6 @@ def uninstall_command( cycode ai-guardrails uninstall --ide cursor # Uninstall from Cursor IDE cycode ai-guardrails uninstall --ide all # Uninstall from all supported IDEs """ - add_breadcrumb('ai-guardrails-uninstall') - # Validate inputs validate_scope(scope) repo_path = resolve_repo_path(scope, repo_path) diff --git a/cycode/cli/apps/auth/auth_command.py b/cycode/cli/apps/auth/auth_command.py index 817e0213..1184a916 100644 --- a/cycode/cli/apps/auth/auth_command.py +++ b/cycode/cli/apps/auth/auth_command.py @@ -4,7 +4,6 @@ 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.sentry import add_breadcrumb def auth_command(ctx: typer.Context) -> None: @@ -16,7 +15,6 @@ def auth_command(ctx: typer.Context) -> None: * `cycode auth`: Start interactive authentication * `cycode auth --help`: View authentication options """ - add_breadcrumb('auth') printer = ctx.obj.get('console_printer') try: diff --git a/cycode/cli/apps/configure/configure_command.py b/cycode/cli/apps/configure/configure_command.py index a8759459..1811271c 100644 --- a/cycode/cli/apps/configure/configure_command.py +++ b/cycode/cli/apps/configure/configure_command.py @@ -10,7 +10,6 @@ get_id_token_input, ) from cycode.cli.console import console -from cycode.cli.utils.sentry import add_breadcrumb def _should_update_value( @@ -39,8 +38,6 @@ def configure_command() -> None: * `cycode configure`: Start interactive configuration * `cycode configure --help`: View configuration options """ - 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/apps/ignore/ignore_command.py b/cycode/cli/apps/ignore/ignore_command.py index 1183114a..c65197c3 100644 --- a/cycode/cli/apps/ignore/ignore_command.py +++ b/cycode/cli/apps/ignore/ignore_command.py @@ -9,7 +9,6 @@ from cycode.cli.config import configuration_manager from cycode.cli.logger import logger from cycode.cli.utils.path_utils import get_absolute_path, is_path_exists -from cycode.cli.utils.sentry import add_breadcrumb from cycode.cli.utils.string_utils import hash_string_to_sha256 _FILTER_BY_RICH_HELP_PANEL = 'Filter options' @@ -97,8 +96,6 @@ def ignore_command( # noqa: C901 * `cycode ignore --by-rule GUID`: Ignore rule with the specified GUID * `cycode ignore --by-package lodash@4.17.21`: Ignore lodash version 4.17.21 """ - add_breadcrumb('ignore') - all_by_values = [by_value, by_sha, by_path, by_rule, by_package, by_cve] if all(by is None for by in all_by_values): raise click.ClickException('Ignore by type is missing') diff --git a/cycode/cli/apps/mcp/mcp_command.py b/cycode/cli/apps/mcp/mcp_command.py index b9989ce2..39bcce40 100644 --- a/cycode/cli/apps/mcp/mcp_command.py +++ b/cycode/cli/apps/mcp/mcp_command.py @@ -13,7 +13,6 @@ from pydantic import Field from cycode.cli.cli_types import McpTransportOption, ScanTypeOption -from cycode.cli.utils.sentry import add_breadcrumb from cycode.logger import LoggersManager, get_logger try: @@ -381,8 +380,6 @@ def mcp_command( cycode mcp # Start with default transport (stdio) cycode mcp -t sse -p 8080 # Start with Server-Sent Events (SSE) transport on port 8080 """ - add_breadcrumb('mcp') - try: _run_mcp_server(transport, host, port) except Exception as e: diff --git a/cycode/cli/apps/report/report_command.py b/cycode/cli/apps/report/report_command.py index 75debb33..ba19be1c 100644 --- a/cycode/cli/apps/report/report_command.py +++ b/cycode/cli/apps/report/report_command.py @@ -1,7 +1,6 @@ import typer from cycode.cli.utils.progress_bar import SBOM_REPORT_PROGRESS_BAR_SECTIONS, get_progress_bar -from cycode.cli.utils.sentry import add_breadcrumb def report_command(ctx: typer.Context) -> int: @@ -10,6 +9,5 @@ def report_command(ctx: typer.Context) -> int: Example usage: * `cycode report sbom`: Generate SBOM report """ - add_breadcrumb('report') ctx.obj['progress_bar'] = get_progress_bar(hidden=False, sections=SBOM_REPORT_PROGRESS_BAR_SECTIONS) return 1 diff --git a/cycode/cli/apps/report/sbom/path/path_command.py b/cycode/cli/apps/report/sbom/path/path_command.py index 61c9ddb7..93be3d3c 100644 --- a/cycode/cli/apps/report/sbom/path/path_command.py +++ b/cycode/cli/apps/report/sbom/path/path_command.py @@ -13,7 +13,6 @@ from cycode.cli.utils.get_api_client import get_report_cycode_client from cycode.cli.utils.progress_bar import SbomReportProgressBarSection from cycode.cli.utils.scan_utils import is_cycodeignore_allowed_by_scan_config -from cycode.cli.utils.sentry import add_breadcrumb def path_command( @@ -23,8 +22,6 @@ def path_command( typer.Argument(exists=True, resolve_path=True, help='Path to generate SBOM report for.', show_default=False), ], ) -> None: - add_breadcrumb('path') - client = get_report_cycode_client(ctx) report_parameters = ctx.obj['report_parameters'] output_format = report_parameters.output_format diff --git a/cycode/cli/apps/report/sbom/repository_url/repository_url_command.py b/cycode/cli/apps/report/sbom/repository_url/repository_url_command.py index e0955871..2b208ea2 100644 --- a/cycode/cli/apps/report/sbom/repository_url/repository_url_command.py +++ b/cycode/cli/apps/report/sbom/repository_url/repository_url_command.py @@ -7,7 +7,6 @@ from cycode.cli.exceptions.handle_report_sbom_errors import handle_report_exception from cycode.cli.utils.get_api_client import get_report_cycode_client from cycode.cli.utils.progress_bar import SbomReportProgressBarSection -from cycode.cli.utils.sentry import add_breadcrumb from cycode.cli.utils.url_utils import sanitize_repository_url from cycode.logger import get_logger @@ -18,8 +17,6 @@ def repository_url_command( ctx: typer.Context, uri: Annotated[str, typer.Argument(help='Repository URL to generate SBOM report for.', show_default=False)], ) -> None: - add_breadcrumb('repository_url') - progress_bar = ctx.obj['progress_bar'] progress_bar.start() progress_bar.set_section_length(SbomReportProgressBarSection.PREPARE_LOCAL_FILES) diff --git a/cycode/cli/apps/report/sbom/sbom_command.py b/cycode/cli/apps/report/sbom/sbom_command.py index 06126dd0..4454a966 100644 --- a/cycode/cli/apps/report/sbom/sbom_command.py +++ b/cycode/cli/apps/report/sbom/sbom_command.py @@ -5,7 +5,6 @@ import typer from cycode.cli.cli_types import SbomFormatOption, SbomOutputFormatOption -from cycode.cli.utils.sentry import add_breadcrumb from cycode.cyclient.report_client import ReportParameters _OUTPUT_RICH_HELP_PANEL = 'Output options' @@ -50,8 +49,6 @@ def sbom_command( ] = False, ) -> int: """Generate SBOM report.""" - add_breadcrumb('sbom') - sbom_format_parts = sbom_format.split('-') if len(sbom_format_parts) != 2: raise click.ClickException('Invalid SBOM format.') diff --git a/cycode/cli/apps/report_import/report_import_command.py b/cycode/cli/apps/report_import/report_import_command.py index 7f4e8844..3e346bbe 100644 --- a/cycode/cli/apps/report_import/report_import_command.py +++ b/cycode/cli/apps/report_import/report_import_command.py @@ -1,7 +1,5 @@ import typer -from cycode.cli.utils.sentry import add_breadcrumb - def report_import_command(ctx: typer.Context) -> int: """:bar_chart: [bold cyan]Import security reports.[/] @@ -9,5 +7,4 @@ def report_import_command(ctx: typer.Context) -> int: Example usage: * `cycode import sbom`: Import SBOM report """ - add_breadcrumb('import') return 1 diff --git a/cycode/cli/apps/report_import/sbom/sbom_command.py b/cycode/cli/apps/report_import/sbom/sbom_command.py index de9e85d4..b6b5dfeb 100644 --- a/cycode/cli/apps/report_import/sbom/sbom_command.py +++ b/cycode/cli/apps/report_import/sbom/sbom_command.py @@ -6,7 +6,6 @@ from cycode.cli.cli_types import BusinessImpactOption from cycode.cli.exceptions.handle_report_sbom_errors import handle_report_exception from cycode.cli.utils.get_api_client import get_import_sbom_cycode_client -from cycode.cli.utils.sentry import add_breadcrumb from cycode.cyclient.import_sbom_client import ImportSbomParameters @@ -52,8 +51,6 @@ def sbom_command( ] = BusinessImpactOption.MEDIUM, ) -> None: """Import SBOM.""" - add_breadcrumb('sbom') - client = get_import_sbom_cycode_client(ctx) import_parameters = ImportSbomParameters( diff --git a/cycode/cli/apps/scan/commit_history/commit_history_command.py b/cycode/cli/apps/scan/commit_history/commit_history_command.py index 5935cf59..46d911e8 100644 --- a/cycode/cli/apps/scan/commit_history/commit_history_command.py +++ b/cycode/cli/apps/scan/commit_history/commit_history_command.py @@ -6,7 +6,6 @@ from cycode.cli.apps.scan.commit_range_scanner import scan_commit_range from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception from cycode.cli.logger import logger -from cycode.cli.utils.sentry import add_breadcrumb def commit_history_command( @@ -25,8 +24,6 @@ def commit_history_command( ] = '--all', ) -> None: try: - add_breadcrumb('commit_history') - logger.debug('Starting commit history scan process, %s', {'path': path, 'commit_range': commit_range}) scan_commit_range(ctx, repo_path=str(path), commit_range=commit_range) except Exception as e: diff --git a/cycode/cli/apps/scan/path/path_command.py b/cycode/cli/apps/scan/path/path_command.py index 3ee87350..6b2beab5 100644 --- a/cycode/cli/apps/scan/path/path_command.py +++ b/cycode/cli/apps/scan/path/path_command.py @@ -5,7 +5,6 @@ from cycode.cli.apps.scan.code_scanner import scan_disk_files from cycode.cli.logger import logger -from cycode.cli.utils.sentry import add_breadcrumb def path_command( @@ -14,8 +13,6 @@ def path_command( list[Path], typer.Argument(exists=True, resolve_path=True, help='Paths to scan', show_default=False) ], ) -> None: - add_breadcrumb('path') - progress_bar = ctx.obj['progress_bar'] progress_bar.start() diff --git a/cycode/cli/apps/scan/pre_commit/pre_commit_command.py b/cycode/cli/apps/scan/pre_commit/pre_commit_command.py index 5693412f..e0cbc7a8 100644 --- a/cycode/cli/apps/scan/pre_commit/pre_commit_command.py +++ b/cycode/cli/apps/scan/pre_commit/pre_commit_command.py @@ -4,15 +4,12 @@ import typer from cycode.cli.apps.scan.commit_range_scanner import scan_pre_commit -from cycode.cli.utils.sentry import add_breadcrumb def pre_commit_command( ctx: typer.Context, _: Annotated[Optional[list[str]], typer.Argument(help='Ignored arguments', hidden=True)] = None, ) -> None: - add_breadcrumb('pre_commit') - repo_path = os.getcwd() # change locally for easy testing progress_bar = ctx.obj['progress_bar'] diff --git a/cycode/cli/apps/scan/pre_push/pre_push_command.py b/cycode/cli/apps/scan/pre_push/pre_push_command.py index 868ab62e..d3339ea9 100644 --- a/cycode/cli/apps/scan/pre_push/pre_push_command.py +++ b/cycode/cli/apps/scan/pre_push/pre_push_command.py @@ -19,7 +19,6 @@ ) from cycode.cli.logger import logger from cycode.cli.utils import scan_utils -from cycode.cli.utils.sentry import add_breadcrumb from cycode.cli.utils.task_timer import TimeoutAfter from cycode.logger import set_logging_level @@ -29,8 +28,6 @@ def pre_push_command( _: Annotated[Optional[list[str]], typer.Argument(help='Ignored arguments', hidden=True)] = None, ) -> None: try: - add_breadcrumb('pre_push') - if should_skip_pre_receive_scan(): logger.info( 'A scan has been skipped as per your request. ' diff --git a/cycode/cli/apps/scan/pre_receive/pre_receive_command.py b/cycode/cli/apps/scan/pre_receive/pre_receive_command.py index f6265fd2..70abd4aa 100644 --- a/cycode/cli/apps/scan/pre_receive/pre_receive_command.py +++ b/cycode/cli/apps/scan/pre_receive/pre_receive_command.py @@ -19,7 +19,6 @@ ) from cycode.cli.logger import logger from cycode.cli.utils import scan_utils -from cycode.cli.utils.sentry import add_breadcrumb from cycode.cli.utils.task_timer import TimeoutAfter from cycode.logger import set_logging_level @@ -29,8 +28,6 @@ def pre_receive_command( _: Annotated[Optional[list[str]], typer.Argument(help='Ignored arguments', hidden=True)] = None, ) -> None: try: - add_breadcrumb('pre_receive') - if should_skip_pre_receive_scan(): logger.info( 'A scan has been skipped as per your request. ' diff --git a/cycode/cli/apps/scan/repository/repository_command.py b/cycode/cli/apps/scan/repository/repository_command.py index f36c07e6..e32fec0d 100644 --- a/cycode/cli/apps/scan/repository/repository_command.py +++ b/cycode/cli/apps/scan/repository/repository_command.py @@ -17,7 +17,6 @@ from cycode.cli.utils.path_utils import get_path_by_os from cycode.cli.utils.progress_bar import ScanProgressBarSection from cycode.cli.utils.scan_utils import is_cycodeignore_allowed_by_scan_config -from cycode.cli.utils.sentry import add_breadcrumb def repository_command( @@ -30,8 +29,6 @@ def repository_command( ] = None, ) -> None: try: - add_breadcrumb('repository') - logger.debug('Starting repository scan process, %s', {'path': path, 'branch': branch}) scan_type = ctx.obj['scan_type'] diff --git a/cycode/cli/apps/scan/scan_ci/scan_ci_command.py b/cycode/cli/apps/scan/scan_ci/scan_ci_command.py index 4303cda2..7874a054 100644 --- a/cycode/cli/apps/scan/scan_ci/scan_ci_command.py +++ b/cycode/cli/apps/scan/scan_ci/scan_ci_command.py @@ -5,7 +5,6 @@ from cycode.cli.apps.scan.commit_range_scanner import scan_commit_range from cycode.cli.apps.scan.scan_ci.ci_integrations import get_commit_range -from cycode.cli.utils.sentry import add_breadcrumb # This command is not finished yet. It is not used in the codebase. @@ -16,5 +15,4 @@ ) @click.pass_context def scan_ci_command(ctx: typer.Context) -> None: - add_breadcrumb('ci') scan_commit_range(ctx, repo_path=os.getcwd(), commit_range=get_commit_range()) diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 2eb51f12..9892f1b6 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -14,7 +14,6 @@ from cycode.cli.files_collector.file_excluder import excluder from cycode.cli.utils import scan_utils from cycode.cli.utils.get_api_client import get_scan_cycode_client -from cycode.cli.utils.sentry import add_breadcrumb _EXPORT_RICH_HELP_PANEL = 'Export options' _SCA_RICH_HELP_PANEL = 'SCA options' @@ -136,8 +135,6 @@ def scan_command( * `cycode scan commit-history `: Scan the commit history of a local Git repository. """ - add_breadcrumb('scan') - if export_file and export_type is None: raise typer.BadParameter( 'Export type must be specified when --export-file is provided.', @@ -186,7 +183,6 @@ def _sca_scan_to_context(ctx: typer.Context, sca_scan_user_selected: list[str]) @click.pass_context def scan_command_result_callback(ctx: click.Context, *_, **__) -> None: - add_breadcrumb('scan_finalized') ctx.obj['scan_finalized'] = True progress_bar = ctx.obj.get('progress_bar') diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 0acd887e..8f051edd 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -210,14 +210,6 @@ 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' - # sync scans SYNC_SCAN_TIMEOUT_IN_SECONDS_ENV_VAR_NAME = 'SYNC_SCAN_TIMEOUT_IN_SECONDS' DEFAULT_SYNC_SCAN_TIMEOUT_IN_SECONDS = 180 diff --git a/cycode/cli/exceptions/handle_errors.py b/cycode/cli/exceptions/handle_errors.py index 8d230902..ded1d88c 100644 --- a/cycode/cli/exceptions/handle_errors.py +++ b/cycode/cli/exceptions/handle_errors.py @@ -4,7 +4,6 @@ import typer from cycode.cli.models import CliError, CliErrors -from cycode.cli.utils.sentry import capture_exception def handle_errors( @@ -28,8 +27,6 @@ def handle_errors( if isinstance(err, click.ClickException): raise err - capture_exception(err) - unknown_error = CliError(code='unknown_error', message=str(err)) if return_exception: return unknown_error diff --git a/cycode/cli/user_settings/credentials_manager.py b/cycode/cli/user_settings/credentials_manager.py index 32564b0e..9522981b 100644 --- a/cycode/cli/user_settings/credentials_manager.py +++ b/cycode/cli/user_settings/credentials_manager.py @@ -9,7 +9,6 @@ ) from cycode.cli.user_settings.base_file_manager import BaseFileManager from cycode.cli.user_settings.jwt_creator import JwtCreator -from cycode.cli.utils.sentry import setup_scope_from_access_token class CredentialsManager(BaseFileManager): @@ -77,8 +76,6 @@ 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( @@ -91,7 +88,5 @@ 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/sentry.py b/cycode/cli/utils/sentry.py deleted file mode 100644 index 16b2a982..00000000 --- a/cycode/cli/utils/sentry.py +++ /dev/null @@ -1,112 +0,0 @@ -import logging -from dataclasses import dataclass -from typing import Optional - -import sentry_sdk -from sentry_sdk.integrations.atexit import AtexitIntegration -from sentry_sdk.integrations.dedupe import DedupeIntegration -from sentry_sdk.integrations.excepthook import ExcepthookIntegration -from sentry_sdk.integrations.logging import LoggingIntegration -from sentry_sdk.scrubber import DEFAULT_DENYLIST, EventScrubber - -from cycode import __version__ -from cycode.cli import consts -from cycode.cli.logger import logger -from cycode.cli.utils.jwt_utils import get_user_and_tenant_ids_from_access_token -from cycode.cyclient.config import on_premise_installation - -# 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() -_SENTRY_DISABLED = on_premise_installation - - -def _before_sentry_event_send(event: dict, _: dict) -> Optional[dict]: - if _SENTRY_DISABLED: - # drop all events when Sentry is disabled - return None - - 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(), - server_name='', - 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), - default_integrations=False, - integrations=[ - AtexitIntegration(lambda _, __: None), # disable output to stderr about pending events - ExcepthookIntegration(), - DedupeIntegration(), - LoggingIntegration(), - ], - ) - - -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/cyclient/headers.py b/cycode/cyclient/headers.py index 5d10f69b..937f4333 100644 --- a/cycode/cyclient/headers.py +++ b/cycode/cyclient/headers.py @@ -5,7 +5,6 @@ from cycode import __version__ from cycode.cli import consts from cycode.cli.user_settings.configuration_manager import ConfigurationManager -from cycode.cli.utils.sentry import add_correlation_id_to_scope from cycode.cyclient.logger import logger @@ -42,8 +41,6 @@ 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 3f5f9388..a02636da 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "altgraph" @@ -1529,66 +1529,6 @@ files = [ {file = "ruff-0.11.7.tar.gz", hash = "sha256:655089ad3224070736dc32844fde783454f8558e71f501cb207485fe4eee23d4"}, ] -[[package]] -name = "sentry-sdk" -version = "2.42.1" -description = "Python client for Sentry (https://sentry.io)" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "sentry_sdk-2.42.1-py2.py3-none-any.whl", hash = "sha256:f8716b50c927d3beb41bc88439dc6bcd872237b596df5b14613e2ade104aee02"}, - {file = "sentry_sdk-2.42.1.tar.gz", hash = "sha256:8598cc6edcfe74cb8074ba6a7c15338cdee93d63d3eb9b9943b4b568354ad5b6"}, -] - -[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"] -google-genai = ["google-genai (>=1.29.0)"] -grpcio = ["grpcio (>=1.21.1)", "protobuf (>=3.8.0)"] -http2 = ["httpcore[http2] (==1.*)"] -httpx = ["httpx (>=0.16.0)"] -huey = ["huey (>=2)"] -huggingface-hub = ["huggingface_hub (>=0.22)"] -langchain = ["langchain (>=0.0.210)"] -langgraph = ["langgraph (>=0.6.6)"] -launchdarkly = ["launchdarkly-server-sdk (>=9.8.0)"] -litellm = ["litellm (>=1.77.5)"] -litestar = ["litestar (>=2.0.0)"] -loguru = ["loguru (>=0.5)"] -openai = ["openai (>=1.0.0)", "tiktoken (>=0.3.0)"] -openfeature = ["openfeature-sdk (>=0.7.1)"] -opentelemetry = ["opentelemetry-distro (>=0.35b0)"] -opentelemetry-experimental = ["opentelemetry-distro"] -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)"] -statsig = ["statsig (>=0.55.3)"] -tornado = ["tornado (>=6)"] -unleash = ["UnleashClient (>=6.0.1)"] - [[package]] name = "setuptools" version = "80.9.0" @@ -1903,4 +1843,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "f0854d96f0878d9765ad704e15f5c7b53f2387a81df64a2d04e9221959720662" +content-hash = "318614ab911cb6132de25bea80686d7c9f046971678f4b12fd3e912a9949ce8e" diff --git a/pyproject.toml b/pyproject.toml index 65fa2d65..2bfddf44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,6 @@ arrow = ">=1.0.0,<1.4.0" binaryornot = ">=0.4.4,<0.5.0" requests = ">=2.32.4,<3.0" urllib3 = "1.26.19" # 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" rich = ">=13.9.4, <14" patch-ng = "1.18.1" From e609c858193395a68d9526d397e3676efb5cc24f Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Wed, 4 Feb 2026 18:14:36 +0200 Subject: [PATCH 230/257] CM-58331 fix enum usage (#381) Co-authored-by: Claude Opus 4.5 --- .../cli/apps/ai_guardrails/install_command.py | 2 +- cycode/cli/apps/ai_guardrails/scan/payload.py | 6 ++-- .../ai_guardrails/scan/response_builders.py | 7 ++--- .../apps/ai_guardrails/scan/scan_command.py | 2 +- .../cli/apps/ai_guardrails/status_command.py | 2 +- .../apps/ai_guardrails/uninstall_command.py | 2 +- .../ai_guardrails/scan/test_scan_command.py | 31 +++++++++++++++++++ 7 files changed, 40 insertions(+), 12 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/install_command.py b/cycode/cli/apps/ai_guardrails/install_command.py index 882ebad4..a72d5d4c 100644 --- a/cycode/cli/apps/ai_guardrails/install_command.py +++ b/cycode/cli/apps/ai_guardrails/install_command.py @@ -31,7 +31,7 @@ def install_command( '--ide', help='IDE to install hooks for (e.g., "cursor", "claude-code", or "all" for all IDEs). Defaults to cursor.', ), - ] = AIIDEType.CURSOR, + ] = AIIDEType.CURSOR.value, repo_path: Annotated[ Optional[Path], typer.Option( diff --git a/cycode/cli/apps/ai_guardrails/scan/payload.py b/cycode/cli/apps/ai_guardrails/scan/payload.py index ce72a574..08e96f9a 100644 --- a/cycode/cli/apps/ai_guardrails/scan/payload.py +++ b/cycode/cli/apps/ai_guardrails/scan/payload.py @@ -155,7 +155,7 @@ def from_cursor_payload(cls, payload: dict) -> 'AIHookPayload': generation_id=payload.get('generation_id'), ide_user_email=payload.get('user_email'), model=payload.get('model'), - ide_provider=AIIDEType.CURSOR, + ide_provider=AIIDEType.CURSOR.value, ide_version=payload.get('cursor_version'), prompt=payload.get('prompt', ''), file_path=payload.get('file_path') or payload.get('path'), @@ -213,7 +213,7 @@ def from_claude_code_payload(cls, payload: dict) -> 'AIHookPayload': generation_id=generation_id, ide_user_email=None, # Claude Code doesn't provide this in hook payload model=model, - ide_provider=AIIDEType.CLAUDE_CODE, + ide_provider=AIIDEType.CLAUDE_CODE.value, ide_version=ide_version, prompt=payload.get('prompt', ''), file_path=file_path, @@ -248,7 +248,7 @@ def is_payload_for_ide(payload: dict, ide: str) -> bool: return True @classmethod - def from_payload(cls, payload: dict, tool: str = AIIDEType.CURSOR) -> 'AIHookPayload': + def from_payload(cls, payload: dict, tool: str = AIIDEType.CURSOR.value) -> 'AIHookPayload': """Create AIHookPayload from any tool's payload. Args: diff --git a/cycode/cli/apps/ai_guardrails/scan/response_builders.py b/cycode/cli/apps/ai_guardrails/scan/response_builders.py index f0da71b7..ff0a6aa4 100644 --- a/cycode/cli/apps/ai_guardrails/scan/response_builders.py +++ b/cycode/cli/apps/ai_guardrails/scan/response_builders.py @@ -117,7 +117,7 @@ def deny_prompt(self, user_message: str) -> dict: } -def get_response_builder(ide: str = AIIDEType.CURSOR) -> IDEResponseBuilder: +def get_response_builder(ide: str = AIIDEType.CURSOR.value) -> IDEResponseBuilder: """Get the response builder for a specific IDE. Args: @@ -129,10 +129,7 @@ def get_response_builder(ide: str = AIIDEType.CURSOR) -> IDEResponseBuilder: Raises: ValueError: If the IDE is not supported """ - # Normalize to AIIDEType if string passed - if isinstance(ide, str): - ide = ide.lower() - builder = _RESPONSE_BUILDERS.get(ide) + builder = _RESPONSE_BUILDERS.get(ide.lower()) if not builder: raise ValueError(f'Unsupported IDE: {ide}. Supported IDEs: {list(_RESPONSE_BUILDERS.keys())}') return builder diff --git a/cycode/cli/apps/ai_guardrails/scan/scan_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py index 288b0025..fe1c74a3 100644 --- a/cycode/cli/apps/ai_guardrails/scan/scan_command.py +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -69,7 +69,7 @@ def scan_command( help='IDE that sent the payload (e.g., "cursor"). Defaults to cursor.', hidden=True, ), - ] = AIIDEType.CURSOR, + ] = AIIDEType.CURSOR.value, ) -> None: """Scan content from AI IDE hooks for secrets. diff --git a/cycode/cli/apps/ai_guardrails/status_command.py b/cycode/cli/apps/ai_guardrails/status_command.py index 0808d806..ee1e5bcf 100644 --- a/cycode/cli/apps/ai_guardrails/status_command.py +++ b/cycode/cli/apps/ai_guardrails/status_command.py @@ -28,7 +28,7 @@ def status_command( '--ide', help='IDE to check status for (e.g., "cursor", "claude-code", or "all" for all IDEs). Defaults to cursor.', ), - ] = AIIDEType.CURSOR, + ] = AIIDEType.CURSOR.value, repo_path: Annotated[ Optional[Path], typer.Option( diff --git a/cycode/cli/apps/ai_guardrails/uninstall_command.py b/cycode/cli/apps/ai_guardrails/uninstall_command.py index be4288e3..f7b8341c 100644 --- a/cycode/cli/apps/ai_guardrails/uninstall_command.py +++ b/cycode/cli/apps/ai_guardrails/uninstall_command.py @@ -31,7 +31,7 @@ def uninstall_command( '--ide', help='IDE to uninstall hooks from (e.g., "cursor", "claude-code", "all"). Defaults to cursor.', ), - ] = AIIDEType.CURSOR, + ] = AIIDEType.CURSOR.value, repo_path: Annotated[ Optional[Path], typer.Option( diff --git a/tests/cli/commands/ai_guardrails/scan/test_scan_command.py b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py index d1473c69..4bcb35f2 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_scan_command.py +++ b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py @@ -6,7 +6,9 @@ import pytest from pytest_mock import MockerFixture +from typer.testing import CliRunner +from cycode.cli.apps.ai_guardrails import app as ai_guardrails_app from cycode.cli.apps.ai_guardrails.scan.scan_command import scan_command @@ -136,3 +138,32 @@ def test_claude_code_payload_with_claude_code_ide( mock_scan_command_deps['load_policy'].assert_called_once() mock_scan_command_deps['get_handler'].assert_called_once() mock_handler.assert_called_once() + + +class TestDefaultIdeParameterViaCli: + """Tests that verify default IDE parameter works correctly via CLI invocation.""" + + def test_scan_command_default_ide_via_cli(self, mocker: MockerFixture) -> None: + """Test scan_command works with default --ide when invoked via CLI. + + This test catches issues where Typer converts enum defaults to strings + incorrectly (e.g., AIIDEType.CURSOR becomes 'AIIDEType.CURSOR' instead of 'cursor'). + """ + mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command._initialize_clients') + mocker.patch( + 'cycode.cli.apps.ai_guardrails.scan.scan_command.load_policy', + return_value={'fail_open': True}, + ) + mock_handler = MagicMock(return_value={'continue': True}) + mocker.patch( + 'cycode.cli.apps.ai_guardrails.scan.scan_command.get_handler_for_event', + return_value=mock_handler, + ) + + runner = CliRunner() + payload = json.dumps({'hook_event_name': 'beforeSubmitPrompt', 'prompt': 'test'}) + + # Invoke via CLI without --ide flag to use default + result = runner.invoke(ai_guardrails_app, ['scan'], input=payload) + + assert result.exit_code == 0, f'Command failed: {result.output}' From f673b80befb2bb974105fc8357231f43378b9abb Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Thu, 12 Feb 2026 10:59:15 +0200 Subject: [PATCH 231/257] CM-59486 fix hook auth error message (#382) --- cycode/cli/apps/ai_guardrails/scan/scan_command.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/scan/scan_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py index fe1c74a3..add2bb83 100644 --- a/cycode/cli/apps/ai_guardrails/scan/scan_command.py +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -34,17 +34,17 @@ def _get_auth_error_message(error: Exception) -> str: """Get user-friendly message for authentication errors.""" if isinstance(error, click.ClickException): # Missing credentials - return f'{error.message} Please run `cycode configure` to set up your credentials.' + return f'{error.message} Please run `cycode auth` to set up your credentials.' if isinstance(error, HttpUnauthorizedError): # Invalid/expired credentials return ( 'Unable to authenticate to Cycode. Your credentials are invalid or have expired. ' - 'Please run `cycode configure` to update your credentials.' + 'Please run `cycode auth` to update your credentials.' ) # Fallback - return 'Authentication failed. Please run `cycode configure` to set up your credentials.' + return 'Authentication failed. Please run `cycode auth` to set up your credentials.' def _initialize_clients(ctx: typer.Context) -> None: From 8a53aed077458c3c28c271259bd222e7c4c4dd0e Mon Sep 17 00:00:00 2001 From: Ilia Shkolyar <60312091+ilia-cy@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:14:03 +0200 Subject: [PATCH 232/257] CM-59469 switch SCA/IAC from file_name to file_path in detection_details (#383) Co-authored-by: Claude Opus 4.6 --- cycode/cli/apps/scan/scan_result.py | 2 +- .../cli/printers/tables/sca_table_printer.py | 2 +- cycode/cli/printers/utils/detection_data.py | 2 +- .../utils/detection_ordering/sca_ordering.py | 2 +- tests/cli/commands/scan/test_scan_result.py | 47 +++++++++++++++++++ .../cli/printers/utils/test_detection_data.py | 41 ++++++++++++++++ 6 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 tests/cli/commands/scan/test_scan_result.py create mode 100644 tests/cli/printers/utils/test_detection_data.py diff --git a/cycode/cli/apps/scan/scan_result.py b/cycode/cli/apps/scan/scan_result.py index 31a36368..13fb8576 100644 --- a/cycode/cli/apps/scan/scan_result.py +++ b/cycode/cli/apps/scan/scan_result.py @@ -88,7 +88,7 @@ def _get_file_name_from_detection(scan_type: str, raw_detection: dict) -> str: if scan_type == consts.SECRET_SCAN_TYPE: return _get_secret_file_name_from_detection(raw_detection) - return raw_detection['detection_details']['file_name'] + return raw_detection['detection_details']['file_path'] def _get_secret_file_name_from_detection(raw_detection: dict) -> str: diff --git a/cycode/cli/printers/tables/sca_table_printer.py b/cycode/cli/printers/tables/sca_table_printer.py index c0bedcc7..064d21d1 100644 --- a/cycode/cli/printers/tables/sca_table_printer.py +++ b/cycode/cli/printers/tables/sca_table_printer.py @@ -86,7 +86,7 @@ def _enrich_table_with_values(table: Table, detection: Detection) -> None: table.add_cell(SEVERITY_COLUMN, 'N/A') table.add_cell(REPOSITORY_COLUMN, detection_details.get('repository_name')) - table.add_file_path_cell(CODE_PROJECT_COLUMN, detection_details.get('file_name')) + table.add_file_path_cell(CODE_PROJECT_COLUMN, detection_details.get('file_path')) table.add_cell(ECOSYSTEM_COLUMN, detection_details.get('ecosystem')) table.add_cell(PACKAGE_COLUMN, detection_details.get('package_name')) diff --git a/cycode/cli/printers/utils/detection_data.py b/cycode/cli/printers/utils/detection_data.py index 37bee310..679429a3 100644 --- a/cycode/cli/printers/utils/detection_data.py +++ b/cycode/cli/printers/utils/detection_data.py @@ -105,4 +105,4 @@ def get_detection_file_path(scan_type: str, detection: 'Detection') -> Path: return Path(file_path) - return Path(detection.detection_details.get('file_name', '')) + return Path(detection.detection_details.get('file_path', '')) diff --git a/cycode/cli/printers/utils/detection_ordering/sca_ordering.py b/cycode/cli/printers/utils/detection_ordering/sca_ordering.py index a8be3430..9e1f8022 100644 --- a/cycode/cli/printers/utils/detection_ordering/sca_ordering.py +++ b/cycode/cli/printers/utils/detection_ordering/sca_ordering.py @@ -49,7 +49,7 @@ def sort_and_group_detections(detections: list['Detection']) -> tuple[list['Dete grouped_by_repository = __group_by(sorted_detections, 'repository_name') for repository_group in grouped_by_repository.values(): - grouped_by_code_project = __group_by(repository_group, 'file_name') + grouped_by_code_project = __group_by(repository_group, 'file_path') for code_project_group in grouped_by_code_project.values(): grouped_by_package = __group_by(code_project_group, 'package_name') for package_group in grouped_by_package.values(): diff --git a/tests/cli/commands/scan/test_scan_result.py b/tests/cli/commands/scan/test_scan_result.py new file mode 100644 index 00000000..e85ca116 --- /dev/null +++ b/tests/cli/commands/scan/test_scan_result.py @@ -0,0 +1,47 @@ +import os + +from cycode.cli.apps.scan.scan_result import _get_file_name_from_detection +from cycode.cli.consts import IAC_SCAN_TYPE, SAST_SCAN_TYPE, SCA_SCAN_TYPE, SECRET_SCAN_TYPE + + +def test_get_file_name_from_detection_sca_uses_file_path() -> None: + raw_detection = { + 'detection_details': { + 'file_name': 'package.json', + 'file_path': '/repo/path/package.json', + }, + } + result = _get_file_name_from_detection(SCA_SCAN_TYPE, raw_detection) + assert result == '/repo/path/package.json' + + +def test_get_file_name_from_detection_iac_uses_file_path() -> None: + raw_detection = { + 'detection_details': { + 'file_name': 'main.tf', + 'file_path': '/repo/infra/main.tf', + }, + } + result = _get_file_name_from_detection(IAC_SCAN_TYPE, raw_detection) + assert result == '/repo/infra/main.tf' + + +def test_get_file_name_from_detection_sast_uses_file_path() -> None: + raw_detection = { + 'detection_details': { + 'file_path': '/repo/src/app.py', + }, + } + result = _get_file_name_from_detection(SAST_SCAN_TYPE, raw_detection) + assert result == '/repo/src/app.py' + + +def test_get_file_name_from_detection_secret_uses_file_path_and_file_name() -> None: + raw_detection = { + 'detection_details': { + 'file_path': '/repo/src', + 'file_name': '.env', + }, + } + result = _get_file_name_from_detection(SECRET_SCAN_TYPE, raw_detection) + assert result == os.path.join('/repo/src', '.env') diff --git a/tests/cli/printers/utils/test_detection_data.py b/tests/cli/printers/utils/test_detection_data.py new file mode 100644 index 00000000..603c25db --- /dev/null +++ b/tests/cli/printers/utils/test_detection_data.py @@ -0,0 +1,41 @@ +from pathlib import Path +from unittest.mock import MagicMock + +from cycode.cli.consts import IAC_SCAN_TYPE, SAST_SCAN_TYPE, SCA_SCAN_TYPE, SECRET_SCAN_TYPE +from cycode.cli.printers.utils.detection_data import get_detection_file_path + + +def _make_detection(**details: str) -> MagicMock: + detection = MagicMock() + detection.detection_details = dict(details) + return detection + + +def test_get_detection_file_path_sca_uses_file_path() -> None: + detection = _make_detection(file_name='package.json', file_path='/repo/path/package.json') + result = get_detection_file_path(SCA_SCAN_TYPE, detection) + assert result == Path('/repo/path/package.json') + + +def test_get_detection_file_path_iac_uses_file_path() -> None: + detection = _make_detection(file_name='main.tf', file_path='/repo/infra/main.tf') + result = get_detection_file_path(IAC_SCAN_TYPE, detection) + assert result == Path('/repo/infra/main.tf') + + +def test_get_detection_file_path_sca_fallback_empty() -> None: + detection = _make_detection() + result = get_detection_file_path(SCA_SCAN_TYPE, detection) + assert result == Path('') + + +def test_get_detection_file_path_secret() -> None: + detection = _make_detection(file_path='/repo/src', file_name='.env') + result = get_detection_file_path(SECRET_SCAN_TYPE, detection) + assert result == Path('/repo/src/.env') + + +def test_get_detection_file_path_sast() -> None: + detection = _make_detection(file_path='repo/src/app.py') + result = get_detection_file_path(SAST_SCAN_TYPE, detection) + assert result == Path('/repo/src/app.py') From e4cc4b5b4abe66bdd239aef2c6919d39f0cf2354 Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Mon, 16 Feb 2026 08:04:51 +0000 Subject: [PATCH 233/257] CM-59577: handle 401 errors gracefully in scans (#384) --- cycode/cli/exceptions/custom_exceptions.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/cycode/cli/exceptions/custom_exceptions.py b/cycode/cli/exceptions/custom_exceptions.py index 59c0f693..78781914 100644 --- a/cycode/cli/exceptions/custom_exceptions.py +++ b/cycode/cli/exceptions/custom_exceptions.py @@ -47,12 +47,9 @@ class ReportAsyncError(CycodeError): pass -class HttpUnauthorizedError(RequestError): +class HttpUnauthorizedError(RequestHttpError): def __init__(self, error_message: str, response: Response) -> None: - self.status_code = 401 - self.error_message = error_message - self.response = response - super().__init__(self.error_message) + super().__init__(401, error_message, response) def __str__(self) -> str: return f'HTTP unauthorized error occurred during the request. Message: {self.error_message}' From 5922901fd7937d3acb7b4a283fba492a60b21b94 Mon Sep 17 00:00:00 2001 From: Brad Smith Date: Wed, 18 Feb 2026 01:15:05 -0800 Subject: [PATCH 234/257] Update Python version requirements in README (#387) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 991ba56c..a4457d2d 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ This guide walks you through both installation and usage. # Prerequisites -- The Cycode CLI application requires Python version 3.9 or later. +- The Cycode CLI application requires Python version 3.9 or later. The MCP command is available only for Python 3.10 and above. If you're using an earlier Python version, this command will not be available. - Use the [`cycode auth` command](#using-the-auth-command) to authenticate to Cycode with the CLI - Alternatively, you can get a Cycode Client ID and Client Secret Key by following the steps detailed in the [Service Account Token](https://docs.cycode.com/docs/en/service-accounts) and [Personal Access Token](https://docs.cycode.com/v1/docs/managing-personal-access-tokens) pages, which contain details on getting these values. From b3ae1da3c6431905602f3d130d6bb8150a5a63d6 Mon Sep 17 00:00:00 2001 From: ronens88 <55343081+ronens88@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:23:11 +0200 Subject: [PATCH 235/257] CM-59712: add --maven-settings-file to report sbom path command (#385) Co-authored-by: Cursor Co-authored-by: Philip Hayton --- README.md | 6 ++++++ cycode/cli/apps/report/sbom/path/path_command.py | 16 +++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a4457d2d..2abfd3b2 100644 --- a/README.md +++ b/README.md @@ -1307,6 +1307,12 @@ To create an SBOM report for a path:\ For example:\ `cycode report sbom --format spdx-2.3 --include-vulnerabilities --include-dev-dependencies path /path/to/local/project` +The `path` subcommand supports the following additional options: + +| Option | Description | +|-------------------------|----------------------------------------------------------------------------------------------------------------------------------| +| `--maven-settings-file` | For Maven only, allows using a custom [settings.xml](https://maven.apache.org/settings.html) file when building the dependency tree | + # Import Command ## Importing SBOM diff --git a/cycode/cli/apps/report/sbom/path/path_command.py b/cycode/cli/apps/report/sbom/path/path_command.py index 93be3d3c..a127bfc7 100644 --- a/cycode/cli/apps/report/sbom/path/path_command.py +++ b/cycode/cli/apps/report/sbom/path/path_command.py @@ -1,6 +1,6 @@ import time from pathlib import Path -from typing import Annotated +from typing import Annotated, Optional import typer @@ -14,6 +14,8 @@ from cycode.cli.utils.progress_bar import SbomReportProgressBarSection from cycode.cli.utils.scan_utils import is_cycodeignore_allowed_by_scan_config +_SCA_RICH_HELP_PANEL = 'SCA options' + def path_command( ctx: typer.Context, @@ -21,7 +23,19 @@ def path_command( Path, typer.Argument(exists=True, resolve_path=True, help='Path to generate SBOM report for.', show_default=False), ], + maven_settings_file: Annotated[ + Optional[Path], + typer.Option( + '--maven-settings-file', + show_default=False, + help='When specified, Cycode will use this settings.xml file when building the maven dependency tree.', + dir_okay=False, + rich_help_panel=_SCA_RICH_HELP_PANEL, + ), + ] = None, ) -> None: + ctx.obj['maven_settings_file'] = maven_settings_file + client = get_report_cycode_client(ctx) report_parameters = ctx.obj['report_parameters'] output_format = report_parameters.output_format From 8fa780d569190c51caa7f43d153b0ca46045c9d7 Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Wed, 18 Feb 2026 10:31:59 +0000 Subject: [PATCH 236/257] CM-59691: update build processes and pyinstaller setup (#386) --- .github/workflows/build_executable.yml | 4 +- .github/workflows/tests_full.yml | 3 +- poetry.lock | 58 +++++++++++++------------- pyinstaller.spec | 4 +- pyproject.toml | 2 +- 5 files changed, 36 insertions(+), 35 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index ae14d3a2..333427a3 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -61,10 +61,10 @@ jobs: git checkout $LATEST_TAG echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV - - name: Set up Python 3.12 + - name: Set up Python 3.13 uses: actions/setup-python@v4 with: - python-version: '3.12' + python-version: '3.13' - name: Load cached Poetry setup id: cached-poetry diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index b8d1fc2c..aea09b4a 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -66,8 +66,7 @@ jobs: - name: Run executable test # we care about the one Python version that will be used to build the executable - # TODO(MarshalX): upgrade to Python 3.13 - if: matrix.python-version == '3.12' + if: matrix.python-version == '3.13' run: | poetry run pyinstaller pyinstaller.spec ./dist/cycode-cli version diff --git a/poetry.lock b/poetry.lock index a02636da..b20290ed 100644 --- a/poetry.lock +++ b/poetry.lock @@ -7,7 +7,7 @@ description = "Python graph (network) package" optional = false python-versions = "*" groups = ["executable"] -markers = "python_version < \"3.13\"" +markers = "python_version < \"3.15\"" files = [ {file = "altgraph-0.17.4-py2.py3-none-any.whl", hash = "sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff"}, {file = "altgraph-0.17.4.tar.gz", hash = "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406"}, @@ -584,7 +584,7 @@ description = "Mach-O header analysis and editing" optional = false python-versions = "*" groups = ["executable"] -markers = "python_version < \"3.13\" and sys_platform == \"darwin\"" +markers = "python_version < \"3.15\" and sys_platform == \"darwin\"" files = [ {file = "macholib-1.16.3-py2.py3-none-any.whl", hash = "sha256:0e315d7583d38b8c77e815b1ecbdbf504a8258d8b3e17b61165c6feb60d18f2c"}, {file = "macholib-1.16.3.tar.gz", hash = "sha256:07ae9e15e8e4cd9a788013d81f5908b3609aa76f9b1421bae9c4d7606ec86a30"}, @@ -745,7 +745,7 @@ description = "Python PE parsing module" optional = false python-versions = ">=3.6.0" groups = ["executable"] -markers = "python_version < \"3.13\" and sys_platform == \"win32\"" +markers = "python_version < \"3.15\" and sys_platform == \"win32\"" files = [ {file = "pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f"}, {file = "pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632"}, @@ -973,50 +973,52 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyinstaller" -version = "5.13.2" +version = "6.19.0" description = "PyInstaller bundles a Python application and all its dependencies into a single package." optional = false -python-versions = "<3.13,>=3.7" +python-versions = "<3.15,>=3.8" groups = ["executable"] -markers = "python_version < \"3.13\"" -files = [ - {file = "pyinstaller-5.13.2-py3-none-macosx_10_13_universal2.whl", hash = "sha256:16cbd66b59a37f4ee59373a003608d15df180a0d9eb1a29ff3bfbfae64b23d0f"}, - {file = "pyinstaller-5.13.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8f6dd0e797ae7efdd79226f78f35eb6a4981db16c13325e962a83395c0ec7420"}, - {file = "pyinstaller-5.13.2-py3-none-manylinux2014_i686.whl", hash = "sha256:65133ed89467edb2862036b35d7c5ebd381670412e1e4361215e289c786dd4e6"}, - {file = "pyinstaller-5.13.2-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:7d51734423685ab2a4324ab2981d9781b203dcae42839161a9ee98bfeaabdade"}, - {file = "pyinstaller-5.13.2-py3-none-manylinux2014_s390x.whl", hash = "sha256:2c2fe9c52cb4577a3ac39626b84cf16cf30c2792f785502661286184f162ae0d"}, - {file = "pyinstaller-5.13.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c63ef6133eefe36c4b2f4daf4cfea3d6412ece2ca218f77aaf967e52a95ac9b8"}, - {file = "pyinstaller-5.13.2-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:aadafb6f213549a5906829bb252e586e2cf72a7fbdb5731810695e6516f0ab30"}, - {file = "pyinstaller-5.13.2-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:b2e1c7f5cceb5e9800927ddd51acf9cc78fbaa9e79e822c48b0ee52d9ce3c892"}, - {file = "pyinstaller-5.13.2-py3-none-win32.whl", hash = "sha256:421cd24f26144f19b66d3868b49ed673176765f92fa9f7914cd2158d25b6d17e"}, - {file = "pyinstaller-5.13.2-py3-none-win_amd64.whl", hash = "sha256:ddcc2b36052a70052479a9e5da1af067b4496f43686ca3cdda99f8367d0627e4"}, - {file = "pyinstaller-5.13.2-py3-none-win_arm64.whl", hash = "sha256:27cd64e7cc6b74c5b1066cbf47d75f940b71356166031deb9778a2579bb874c6"}, - {file = "pyinstaller-5.13.2.tar.gz", hash = "sha256:c8e5d3489c3a7cc5f8401c2d1f48a70e588f9967e391c3b06ddac1f685f8d5d2"}, +markers = "python_version < \"3.15\"" +files = [ + {file = "pyinstaller-6.19.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:4190e76b74f0c4b5c5f11ac360928cd2e36ec8e3194d437bf6b8648c7bc0c134"}, + {file = "pyinstaller-6.19.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8bd68abd812d8a6ba33b9f1810e91fee0f325969733721b78151f0065319ca11"}, + {file = "pyinstaller-6.19.0-py3-none-manylinux2014_i686.whl", hash = "sha256:1ec54ef967996ca61dacba676227e2b23219878ccce5ee9d6f3aada7b8ed8abf"}, + {file = "pyinstaller-6.19.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4ab2bb52e58448e14ddf9450601bdedd66800465043501c1d8f1cab87b60b122"}, + {file = "pyinstaller-6.19.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:da6d5c6391ccefe73554b9fa29b86001c8e378e0f20c2a4004f836ba537eff63"}, + {file = "pyinstaller-6.19.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a0fc5f6b3c55aa54353f0c74ffa59b1115433c1850c6f655d62b461a2ed6cbbe"}, + {file = "pyinstaller-6.19.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:e649ba6bd1b0b89b210ad92adb5fbdc8a42dd2c5ca4f72ef3a0bfec83a424b83"}, + {file = "pyinstaller-6.19.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:481a909c8e60c8692fc60fcb1344d984b44b943f8bc9682f2fcdae305ad297e6"}, + {file = "pyinstaller-6.19.0-py3-none-win32.whl", hash = "sha256:3c5c251054fe4cfaa04c34a363dcfbf811545438cb7198304cd444756bc2edd2"}, + {file = "pyinstaller-6.19.0-py3-none-win_amd64.whl", hash = "sha256:b5bb6536c6560330d364d91522250f254b107cf69129d9cbcd0e6727c570be33"}, + {file = "pyinstaller-6.19.0-py3-none-win_arm64.whl", hash = "sha256:c2d5a539b0bfe6159d5522c8c70e1c0e487f22c2badae0f97d45246223b798ea"}, + {file = "pyinstaller-6.19.0.tar.gz", hash = "sha256:ec73aeb8bd9b7f2f1240d328a4542e90b3c6e6fbc106014778431c616592a865"}, ] [package.dependencies] altgraph = "*" +importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""} +packaging = ">=22.0" pefile = {version = ">=2022.5.30", markers = "sys_platform == \"win32\""} -pyinstaller-hooks-contrib = ">=2021.4" +pyinstaller-hooks-contrib = ">=2026.0" pywin32-ctypes = {version = ">=0.2.1", markers = "sys_platform == \"win32\""} setuptools = ">=42.0.0" [package.extras] -encryption = ["tinyaes (>=1.0.0)"] +completion = ["argcomplete"] hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"] [[package]] name = "pyinstaller-hooks-contrib" -version = "2025.9" +version = "2026.0" description = "Community maintained hooks for PyInstaller" optional = false python-versions = ">=3.8" groups = ["executable"] -markers = "python_version < \"3.13\"" +markers = "python_version < \"3.15\"" files = [ - {file = "pyinstaller_hooks_contrib-2025.9-py3-none-any.whl", hash = "sha256:ccbfaa49399ef6b18486a165810155e5a8d4c59b41f20dc5da81af7482aaf038"}, - {file = "pyinstaller_hooks_contrib-2025.9.tar.gz", hash = "sha256:56e972bdaad4e9af767ed47d132362d162112260cbe488c9da7fee01f228a5a6"}, + {file = "pyinstaller_hooks_contrib-2026.0-py3-none-any.whl", hash = "sha256:0590db8edeba3e6c30c8474937021f5cd39c0602b4d10f74a064c73911efaca5"}, + {file = "pyinstaller_hooks_contrib-2026.0.tar.gz", hash = "sha256:0120893de491a000845470ca9c0b39284731ac6bace26f6849dea9627aaed48e"}, ] [package.dependencies] @@ -1165,7 +1167,7 @@ description = "A (partial) reimplementation of pywin32 using ctypes/cffi" optional = false python-versions = ">=3.6" groups = ["executable"] -markers = "python_version < \"3.13\" and sys_platform == \"win32\"" +markers = "python_version < \"3.15\" and sys_platform == \"win32\"" files = [ {file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"}, {file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"}, @@ -1536,7 +1538,7 @@ description = "Easily download, build, install, upgrade, and uninstall Python pa optional = false python-versions = ">=3.9" groups = ["executable"] -markers = "python_version < \"3.13\"" +markers = "python_version < \"3.15\"" files = [ {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, @@ -1843,4 +1845,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "318614ab911cb6132de25bea80686d7c9f046971678f4b12fd3e912a9949ce8e" +content-hash = "d705f54b6e814ba9b361cda482e5f23a7fbd0a41ae652f76ece6bfb78b00973f" diff --git a/pyinstaller.spec b/pyinstaller.spec index 39b8588f..c577c547 100644 --- a/pyinstaller.spec +++ b/pyinstaller.spec @@ -23,10 +23,10 @@ with open(_INIT_FILE_PATH, 'w', encoding='UTF-8') as file: a = Analysis( scripts=['cycode/cli/main.py'], - excludes=['tests'], + excludes=['tests', 'setuptools', 'pkg_resources'], ) -exe_args = [PYZ(a.pure, a.zipped_data), a.scripts, a.binaries, a.zipfiles, a.datas] +exe_args = [PYZ(a.pure), a.scripts, a.binaries, a.datas] if _ONEDIR_MODE: exe_args = [PYZ(a.pure), a.scripts] diff --git a/pyproject.toml b/pyproject.toml index 2bfddf44..06c69b28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,7 +60,7 @@ responses = ">=0.23.1,<0.24.0" pyfakefs = ">=5.7.2,<5.8.0" [tool.poetry.group.executable.dependencies] -pyinstaller = {version=">=5.13.2,<5.14.0", python=">=3.8,<3.13"} +pyinstaller = {version=">=6.0.0,<7.0.0", python=">=3.9,<3.15"} dunamai = ">=1.18.0,<1.22.0" [tool.poetry.group.dev.dependencies] From d956fdb11e5cb72361b4d9a67947d5ca1d43f2a6 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:03:03 +0200 Subject: [PATCH 237/257] CM-59792 read file hook save file path (#389) --- cycode/cli/apps/ai_guardrails/scan/handlers.py | 1 + cycode/cyclient/ai_security_manager_client.py | 2 ++ tests/cli/commands/ai_guardrails/scan/test_handlers.py | 3 +++ 3 files changed, 6 insertions(+) diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index 32be1241..2a762a8d 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -170,6 +170,7 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: scan_id=scan_id, block_reason=block_reason, error_message=error_message, + file_path=payload.file_path, ) diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py index 1090ad8d..35c1d8c9 100644 --- a/cycode/cyclient/ai_security_manager_client.py +++ b/cycode/cyclient/ai_security_manager_client.py @@ -62,6 +62,7 @@ def create_event( scan_id: Optional[str] = None, block_reason: Optional['BlockReason'] = None, error_message: Optional[str] = None, + file_path: Optional[str] = None, ) -> None: """Create an AI hook event from hook payload.""" conversation_id = payload.conversation_id @@ -79,6 +80,7 @@ def create_event( 'mcp_server_name': payload.mcp_server_name, 'mcp_tool_name': payload.mcp_tool_name, 'error_message': error_message, + 'file_path': file_path, } try: diff --git a/tests/cli/commands/ai_guardrails/scan/test_handlers.py b/tests/cli/commands/ai_guardrails/scan/test_handlers.py index 634469b7..1adfe25b 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_handlers.py +++ b/tests/cli/commands/ai_guardrails/scan/test_handlers.py @@ -194,6 +194,7 @@ def test_handle_before_read_file_sensitive_path( call_args = mock_ctx.obj['ai_security_client'].create_event.call_args assert call_args.args[2] == AIHookOutcome.BLOCKED assert call_args.kwargs['block_reason'] == BlockReason.SENSITIVE_PATH + assert call_args.kwargs['file_path'] == '/path/to/.env' @patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') @@ -215,6 +216,7 @@ def test_handle_before_read_file_no_secrets( assert result == {'permission': 'allow'} call_args = mock_ctx.obj['ai_security_client'].create_event.call_args assert call_args.args[2] == AIHookOutcome.ALLOWED + assert call_args.kwargs['file_path'] == '/path/to/file.txt' @patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') @@ -238,6 +240,7 @@ def test_handle_before_read_file_with_secrets( call_args = mock_ctx.obj['ai_security_client'].create_event.call_args assert call_args.args[2] == AIHookOutcome.BLOCKED assert call_args.kwargs['block_reason'] == BlockReason.SECRETS_IN_FILE + assert call_args.kwargs['file_path'] == '/path/to/file.txt' @patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') From 097980b62a2a207c41734811434c06c8a97a26de Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Thu, 19 Feb 2026 09:23:43 +0000 Subject: [PATCH 238/257] CM 59844: update deps (#390) --- poetry.lock | 228 ++++++++- pyproject.toml | 4 +- tests/cli/apps/__init__.py | 0 tests/cli/apps/mcp/__init__.py | 0 tests/cli/apps/mcp/test_mcp_command.py | 315 ++++++++++++ tests/cyclient/test_client_base_exceptions.py | 162 +++++++ tests/test_models_deserialization.py | 451 ++++++++++++++++++ 7 files changed, 1140 insertions(+), 20 deletions(-) create mode 100644 tests/cli/apps/__init__.py create mode 100644 tests/cli/apps/mcp/__init__.py create mode 100644 tests/cli/apps/mcp/test_mcp_command.py create mode 100644 tests/cyclient/test_client_base_exceptions.py create mode 100644 tests/test_models_deserialization.py diff --git a/poetry.lock b/poetry.lock index b20290ed..807fb2f8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -107,6 +107,104 @@ files = [ {file = "certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"}, ] +[[package]] +name = "cffi" +version = "2.0.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.10\" and platform_python_implementation != \"PyPy\"" +files = [ + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, + {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, + {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + [[package]] name = "chardet" version = "5.2.0" @@ -342,6 +440,80 @@ files = [ [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] +[[package]] +name = "cryptography" +version = "46.0.5" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.8" +groups = ["main"] +markers = "python_version >= \"3.10\"" +files = [ + {file = "cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731"}, + {file = "cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82"}, + {file = "cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1"}, + {file = "cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48"}, + {file = "cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4"}, + {file = "cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663"}, + {file = "cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826"}, + {file = "cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d"}, + {file = "cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a"}, + {file = "cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4"}, + {file = "cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c"}, + {file = "cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4"}, + {file = "cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9"}, + {file = "cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72"}, + {file = "cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595"}, + {file = "cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c"}, + {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a"}, + {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356"}, + {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da"}, + {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257"}, + {file = "cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7"}, + {file = "cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} +typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] +docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] +nox = ["nox[uv] (>=2024.4.15)"] +pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"] +sdist = ["build (>=1.0.0)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi (>=2024)", "cryptography-vectors (==46.0.5)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test-randomorder = ["pytest-randomly"] + [[package]] name = "dunamai" version = "1.21.2" @@ -620,35 +792,35 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] [[package]] name = "marshmallow" -version = "3.22.0" +version = "3.26.2" description = "A lightweight library for converting complex datatypes to and from native Python datatypes." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "marshmallow-3.22.0-py3-none-any.whl", hash = "sha256:71a2dce49ef901c3f97ed296ae5051135fd3febd2bf43afe0ae9a82143a494d9"}, - {file = "marshmallow-3.22.0.tar.gz", hash = "sha256:4972f529104a220bb8637d595aa4c9762afbe7f7a77d82dc58c1615d70c5823e"}, + {file = "marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73"}, + {file = "marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57"}, ] [package.dependencies] packaging = ">=17.0" [package.extras] -dev = ["marshmallow[tests]", "pre-commit (>=3.5,<4.0)", "tox"] -docs = ["alabaster (==1.0.0)", "autodocsumm (==0.2.13)", "sphinx (==8.0.2)", "sphinx-issues (==4.1.0)", "sphinx-version-warning (==1.1.2)"] -tests = ["pytest", "pytz", "simplejson"] +dev = ["marshmallow[tests]", "pre-commit (>=3.5,<5.0)", "tox"] +docs = ["autodocsumm (==0.2.14)", "furo (==2024.8.6)", "sphinx (==8.1.3)", "sphinx-copybutton (==0.5.2)", "sphinx-issues (==5.0.0)", "sphinxext-opengraph (==0.9.1)"] +tests = ["pytest", "simplejson"] [[package]] name = "mcp" -version = "1.18.0" +version = "1.26.0" description = "Model Context Protocol SDK" optional = false python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "mcp-1.18.0-py3-none-any.whl", hash = "sha256:42f10c270de18e7892fdf9da259029120b1ea23964ff688248c69db9d72b1d0a"}, - {file = "mcp-1.18.0.tar.gz", hash = "sha256:aa278c44b1efc0a297f53b68df865b988e52dd08182d702019edcf33a8e109f6"}, + {file = "mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca"}, + {file = "mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66"}, ] [package.dependencies] @@ -658,10 +830,13 @@ httpx-sse = ">=0.4" jsonschema = ">=4.20.0" pydantic = ">=2.11.0,<3.0.0" pydantic-settings = ">=2.5.2" +pyjwt = {version = ">=2.10.1", extras = ["crypto"]} python-multipart = ">=0.0.9" pywin32 = {version = ">=310", markers = "sys_platform == \"win32\""} sse-starlette = ">=1.6.1" starlette = ">=0.27" +typing-extensions = ">=4.9.0" +typing-inspection = ">=0.4.1" uvicorn = {version = ">=0.31.1", markers = "sys_platform != \"emscripten\""} [package.extras] @@ -767,6 +942,19 @@ files = [ dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] +[[package]] +name = "pycparser" +version = "3.0" +description = "C parser in Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "python_version >= \"3.10\" and platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" +files = [ + {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, + {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, +] + [[package]] name = "pydantic" version = "2.12.3" @@ -1038,6 +1226,9 @@ files = [ {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, ] +[package.dependencies] +cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} + [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", "sphinx-rtd-theme", "zope.interface"] @@ -1785,20 +1976,21 @@ typing-extensions = ">=4.12.0" [[package]] name = "urllib3" -version = "1.26.19" +version = "2.6.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +python-versions = ">=3.9" groups = ["main", "test"] files = [ - {file = "urllib3-1.26.19-py2.py3-none-any.whl", hash = "sha256:37a0344459b199fce0e80b0d3569837ec6b6937435c5244e7fd73fa6006830f3"}, - {file = "urllib3-1.26.19.tar.gz", hash = "sha256:3e3d753a8618b86d7de333b4223005f68720bcd6a7d2bcb9fbd2229ec7c1e429"}, + {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, + {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, ] [package.extras] -brotli = ["brotli (==1.0.9) ; os_name != \"nt\" and python_version < \"3\" and platform_python_implementation == \"CPython\"", "brotli (>=1.0.9) ; python_version >= \"3\" and platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; (os_name != \"nt\" or python_version >= \"3\") and platform_python_implementation != \"CPython\"", "brotlipy (>=0.6.0) ; os_name == \"nt\" and python_version < \"3\""] -secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress ; python_version == \"2.7\"", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] -socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "uvicorn" @@ -1845,4 +2037,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "d705f54b6e814ba9b361cda482e5f23a7fbd0a41ae652f76ece6bfb78b00973f" +content-hash = "593c613fcd6438e2133d90f3777c2050738bfa42bc7f5512e43c612b784a9870" diff --git a/pyproject.toml b/pyproject.toml index 06c69b28..cc6297c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,12 +36,12 @@ version = "0.0.0" # DON'T TOUCH. Placeholder. Will be filled automatically on po click = ">=8.1.0,<8.2.0" colorama = ">=0.4.3,<0.5.0" pyyaml = ">=6.0,<7.0" -marshmallow = ">=3.15.0,<3.23.0" # 3.23 dropped support for Python 3.8 +marshmallow = ">=3.15.0,<4.0.0" gitpython = ">=3.1.30,<3.2.0" arrow = ">=1.0.0,<1.4.0" binaryornot = ">=0.4.4,<0.5.0" requests = ">=2.32.4,<3.0" -urllib3 = "1.26.19" # lock v1 to avoid issues with openssl and old Python versions (<3.9.11) on macOS +urllib3 = ">=2.4.0,<3.0.0" pyjwt = ">=2.8.0,<3.0" rich = ">=13.9.4, <14" patch-ng = "1.18.1" diff --git a/tests/cli/apps/__init__.py b/tests/cli/apps/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/apps/mcp/__init__.py b/tests/cli/apps/mcp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/apps/mcp/test_mcp_command.py b/tests/cli/apps/mcp/test_mcp_command.py new file mode 100644 index 00000000..ebcc2373 --- /dev/null +++ b/tests/cli/apps/mcp/test_mcp_command.py @@ -0,0 +1,315 @@ +import json +import os +import sys +from unittest.mock import AsyncMock, patch + +import pytest + +if sys.version_info < (3, 10): + pytest.skip('MCP requires Python 3.10+', allow_module_level=True) + +from cycode.cli.apps.mcp.mcp_command import ( + _sanitize_file_path, + _TempFilesManager, +) + +pytestmark = pytest.mark.anyio + + +@pytest.fixture +def anyio_backend() -> str: + return 'asyncio' + + +# --- _sanitize_file_path input validation --- + + +def test_sanitize_file_path_rejects_empty_string() -> None: + with pytest.raises(ValueError, match='non-empty string'): + _sanitize_file_path('') + + +def test_sanitize_file_path_rejects_none() -> None: + with pytest.raises(ValueError, match='non-empty string'): + _sanitize_file_path(None) + + +def test_sanitize_file_path_rejects_non_string() -> None: + with pytest.raises(ValueError, match='non-empty string'): + _sanitize_file_path(123) + + +def test_sanitize_file_path_strips_null_bytes() -> None: + result = _sanitize_file_path('foo/bar\x00baz.py') + assert '\x00' not in result + + +def test_sanitize_file_path_passes_valid_path_through() -> None: + result = _sanitize_file_path('src/main.py') + assert os.path.normpath(result) == os.path.normpath('src/main.py') + + +# --- _TempFilesManager: path traversal prevention --- +# +# _sanitize_file_path delegates to pathvalidate which does NOT block +# path traversal (../ passes through). The real security boundary is +# the normpath containment check in _TempFilesManager.__enter__ (lines 136-139). +# These tests verify that the two layers together prevent escaping the temp dir. + + +def test_traversal_simple_dotdot_rejected() -> None: + """../../../etc/passwd must not escape the temp directory.""" + files = { + '../../../etc/passwd': 'malicious', + 'safe.py': 'ok', + } + with _TempFilesManager(files, 'test-traversal') as temp_files: + assert len(temp_files) == 1 + assert temp_files[0].endswith('safe.py') + for tf in temp_files: + assert '/etc/passwd' not in tf + + +def test_traversal_backslash_dotdot_rejected() -> None: + """..\\..\\windows\\system32 must not escape the temp directory.""" + files = { + '..\\..\\windows\\system32\\config': 'malicious', + 'safe.py': 'ok', + } + with _TempFilesManager(files, 'test-backslash') as temp_files: + assert len(temp_files) == 1 + assert temp_files[0].endswith('safe.py') + + +def test_traversal_embedded_dotdot_rejected() -> None: + """foo/../../../etc/passwd resolves outside temp dir and must be rejected.""" + files = { + 'foo/../../../etc/passwd': 'malicious', + 'safe.py': 'ok', + } + with _TempFilesManager(files, 'test-embedded') as temp_files: + assert len(temp_files) == 1 + assert temp_files[0].endswith('safe.py') + + +def test_traversal_absolute_path_rejected() -> None: + """Absolute paths must not be written outside the temp directory.""" + files = { + '/etc/passwd': 'malicious', + 'safe.py': 'ok', + } + with _TempFilesManager(files, 'test-absolute') as temp_files: + assert len(temp_files) == 1 + assert temp_files[0].endswith('safe.py') + + +def test_traversal_dotdot_only_rejected() -> None: + """A bare '..' path must be rejected.""" + files = { + '..': 'malicious', + 'safe.py': 'ok', + } + with _TempFilesManager(files, 'test-bare-dotdot') as temp_files: + assert len(temp_files) == 1 + + +def test_traversal_all_malicious_raises() -> None: + """If every file path is a traversal attempt, no files are created and ValueError is raised.""" + files = { + '../../../etc/passwd': 'malicious', + '../../shadow': 'also malicious', + } + with pytest.raises(ValueError, match='No valid files'), _TempFilesManager(files, 'test-all-malicious'): + pass + + +def test_all_created_files_are_inside_temp_dir() -> None: + """Every created file must be under the temp base directory.""" + files = { + 'a.py': 'aaa', + 'sub/b.py': 'bbb', + 'sub/deep/c.py': 'ccc', + } + manager = _TempFilesManager(files, 'test-containment') + with manager as temp_files: + base = os.path.normcase(os.path.normpath(manager.temp_base_dir)) + for tf in temp_files: + normalized = os.path.normcase(os.path.normpath(tf)) + assert normalized.startswith(base + os.sep), f'{tf} escaped temp dir {base}' + + +def test_mixed_valid_and_traversal_only_creates_valid() -> None: + """Valid files are created, traversal attempts are silently skipped.""" + files = { + '../escape.py': 'bad', + 'legit.py': 'good', + 'foo/../../escape2.py': 'bad', + 'src/app.py': 'good', + } + manager = _TempFilesManager(files, 'test-mixed') + with manager as temp_files: + base = os.path.normcase(os.path.normpath(manager.temp_base_dir)) + assert len(temp_files) == 2 + for tf in temp_files: + assert os.path.normcase(os.path.normpath(tf)).startswith(base + os.sep) + basenames = [os.path.basename(tf) for tf in temp_files] + assert 'legit.py' in basenames + assert 'app.py' in basenames + + +# --- _TempFilesManager: general functionality --- + + +def test_temp_files_manager_creates_files() -> None: + files = { + 'test1.py': 'print("hello")', + 'subdir/test2.js': 'console.log("world")', + } + with _TempFilesManager(files, 'test-call-id') as temp_files: + assert len(temp_files) == 2 + for tf in temp_files: + assert os.path.exists(tf) + + +def test_temp_files_manager_writes_correct_content() -> None: + files = {'hello.py': 'print("hello world")'} + with _TempFilesManager(files, 'test-content') as temp_files, open(temp_files[0]) as f: + assert f.read() == 'print("hello world")' + + +def test_temp_files_manager_cleans_up_on_exit() -> None: + files = {'cleanup.py': 'code'} + manager = _TempFilesManager(files, 'test-cleanup') + with manager as temp_files: + temp_dir = manager.temp_base_dir + assert os.path.exists(temp_dir) + assert len(temp_files) == 1 + assert not os.path.exists(temp_dir) + + +def test_temp_files_manager_empty_path_raises() -> None: + files = {'': 'empty path'} + with pytest.raises(ValueError, match='No valid files'), _TempFilesManager(files, 'test-empty-path'): + pass + + +def test_temp_files_manager_preserves_subdirectory_structure() -> None: + files = { + 'src/main.py': 'main', + 'src/utils/helper.py': 'helper', + } + with _TempFilesManager(files, 'test-dirs') as temp_files: + assert len(temp_files) == 2 + paths = [os.path.basename(tf) for tf in temp_files] + assert 'main.py' in paths + assert 'helper.py' in paths + + +# --- _run_cycode_command (async) --- + + +@pytest.mark.anyio +async def test_run_cycode_command_returns_dict() -> None: + from cycode.cli.apps.mcp.mcp_command import _run_cycode_command + + mock_process = AsyncMock() + mock_process.communicate.return_value = (b'', b'error output') + mock_process.returncode = 1 + + with patch('asyncio.create_subprocess_exec', return_value=mock_process): + result = await _run_cycode_command('--invalid-flag-for-test') + assert isinstance(result, dict) + assert 'error' in result + + +@pytest.mark.anyio +async def test_run_cycode_command_parses_json_output() -> None: + from cycode.cli.apps.mcp.mcp_command import _run_cycode_command + + mock_process = AsyncMock() + mock_process.communicate.return_value = (b'{"status": "ok"}', b'') + mock_process.returncode = 0 + + with patch('asyncio.create_subprocess_exec', return_value=mock_process): + result = await _run_cycode_command('version') + assert result == {'status': 'ok'} + + +@pytest.mark.anyio +async def test_run_cycode_command_handles_invalid_json() -> None: + from cycode.cli.apps.mcp.mcp_command import _run_cycode_command + + mock_process = AsyncMock() + mock_process.communicate.return_value = (b'not json{', b'') + mock_process.returncode = 0 + + with patch('asyncio.create_subprocess_exec', return_value=mock_process): + result = await _run_cycode_command('version') + assert result['error'] == 'Failed to parse JSON output' + + +@pytest.mark.anyio +async def test_run_cycode_command_timeout() -> None: + import asyncio + + from cycode.cli.apps.mcp.mcp_command import _run_cycode_command + + async def slow_communicate() -> tuple[bytes, bytes]: + await asyncio.sleep(10) + return b'', b'' + + mock_process = AsyncMock() + mock_process.communicate = slow_communicate + + with patch('asyncio.create_subprocess_exec', return_value=mock_process): + result = await _run_cycode_command('status', timeout=0.001) + assert isinstance(result, dict) + assert 'error' in result + assert 'timeout' in result['error'].lower() + + +# --- _cycode_scan_tool --- + + +@pytest.mark.anyio +async def test_cycode_scan_tool_no_files() -> None: + from cycode.cli.apps.mcp.mcp_command import _cycode_scan_tool + from cycode.cli.cli_types import ScanTypeOption + + result = await _cycode_scan_tool(ScanTypeOption.SECRET, {}) + parsed = json.loads(result) + assert 'error' in parsed + assert 'No files provided' in parsed['error'] + + +@pytest.mark.anyio +async def test_cycode_scan_tool_invalid_files() -> None: + from cycode.cli.apps.mcp.mcp_command import _cycode_scan_tool + from cycode.cli.cli_types import ScanTypeOption + + result = await _cycode_scan_tool(ScanTypeOption.SECRET, {'': 'content'}) + parsed = json.loads(result) + assert 'error' in parsed + + +# --- _create_mcp_server --- + + +def test_create_mcp_server() -> None: + from cycode.cli.apps.mcp.mcp_command import _create_mcp_server + + server = _create_mcp_server('127.0.0.1', 8000) + assert server is not None + assert server.name == 'cycode' + + +def test_create_mcp_server_registers_tools() -> None: + from cycode.cli.apps.mcp.mcp_command import _create_mcp_server + + server = _create_mcp_server('127.0.0.1', 8000) + tool_names = [t.name for t in server._tool_manager._tools.values()] + assert 'cycode_status' in tool_names + assert 'cycode_secret_scan' in tool_names + assert 'cycode_sca_scan' in tool_names + assert 'cycode_iac_scan' in tool_names + assert 'cycode_sast_scan' in tool_names diff --git a/tests/cyclient/test_client_base_exceptions.py b/tests/cyclient/test_client_base_exceptions.py new file mode 100644 index 00000000..f99453d3 --- /dev/null +++ b/tests/cyclient/test_client_base_exceptions.py @@ -0,0 +1,162 @@ +from unittest.mock import MagicMock + +import pytest +import responses +from requests.exceptions import ( + ConnectionError as RequestsConnectionError, +) +from requests.exceptions import ( + HTTPError, + SSLError, + Timeout, +) + +from cycode.cli.exceptions.custom_exceptions import ( + HttpUnauthorizedError, + RequestConnectionError, + RequestHttpError, + RequestSslError, + RequestTimeoutError, +) +from cycode.cyclient import config +from cycode.cyclient.cycode_client_base import CycodeClientBase + + +def _make_client() -> CycodeClientBase: + return CycodeClientBase(config.cycode_api_url) + + +# --- _handle_exception mapping --- + + +def test_handle_exception_timeout() -> None: + client = _make_client() + with pytest.raises(RequestTimeoutError): + client._handle_exception(Timeout('timed out')) + + +def test_handle_exception_ssl_error() -> None: + client = _make_client() + with pytest.raises(RequestSslError): + client._handle_exception(SSLError('cert verify failed')) + + +def test_handle_exception_connection_error() -> None: + client = _make_client() + with pytest.raises(RequestConnectionError): + client._handle_exception(RequestsConnectionError('refused')) + + +def test_handle_exception_http_error_401() -> None: + response = MagicMock() + response.status_code = 401 + response.text = 'Unauthorized' + error = HTTPError(response=response) + + client = _make_client() + with pytest.raises(HttpUnauthorizedError): + client._handle_exception(error) + + +def test_handle_exception_http_error_500() -> None: + response = MagicMock() + response.status_code = 500 + response.text = 'Internal Server Error' + error = HTTPError(response=response) + + client = _make_client() + with pytest.raises(RequestHttpError) as exc_info: + client._handle_exception(error) + assert exc_info.value.status_code == 500 + + +def test_handle_exception_unknown_error_reraises() -> None: + client = _make_client() + with pytest.raises(RuntimeError, match='something unexpected'): + client._handle_exception(RuntimeError('something unexpected')) + + +# --- HTTP integration via responses mock --- + + +@responses.activate +def test_get_returns_response_on_success() -> None: + client = _make_client() + url = f'{client.api_url}/test-endpoint' + responses.add(responses.GET, url, json={'ok': True}, status=200) + + response = client.get('test-endpoint') + assert response.status_code == 200 + assert response.json() == {'ok': True} + + +@responses.activate +def test_post_returns_response_on_success() -> None: + client = _make_client() + url = f'{client.api_url}/test-endpoint' + responses.add(responses.POST, url, json={'created': True}, status=201) + + response = client.post('test-endpoint', body={'data': 'value'}) + assert response.status_code == 201 + + +@responses.activate +def test_get_raises_timeout_error() -> None: + client = _make_client() + url = f'{client.api_url}/slow-endpoint' + responses.add(responses.GET, url, body=Timeout('Connection timed out')) + + with pytest.raises(RequestTimeoutError): + client.get('slow-endpoint') + + +@responses.activate +def test_get_raises_ssl_error() -> None: + client = _make_client() + url = f'{client.api_url}/ssl-endpoint' + responses.add(responses.GET, url, body=SSLError('certificate verify failed')) + + with pytest.raises(RequestSslError): + client.get('ssl-endpoint') + + +@responses.activate +def test_get_raises_connection_error() -> None: + client = _make_client() + url = f'{client.api_url}/down-endpoint' + responses.add(responses.GET, url, body=RequestsConnectionError('Connection refused')) + + with pytest.raises(RequestConnectionError): + client.get('down-endpoint') + + +@responses.activate +def test_get_raises_http_unauthorized_error() -> None: + client = _make_client() + url = f'{client.api_url}/auth-endpoint' + responses.add(responses.GET, url, json={'error': 'unauthorized'}, status=401) + + with pytest.raises(HttpUnauthorizedError): + client.get('auth-endpoint') + + +@responses.activate +def test_get_raises_http_error_on_500() -> None: + client = _make_client() + url = f'{client.api_url}/error-endpoint' + responses.add(responses.GET, url, json={'error': 'server error'}, status=500) + + with pytest.raises(RequestHttpError) as exc_info: + client.get('error-endpoint') + assert exc_info.value.status_code == 500 + + +@responses.activate +def test_get_raises_http_error_on_403() -> None: + client = _make_client() + url = f'{client.api_url}/forbidden-endpoint' + responses.add(responses.GET, url, json={'error': 'forbidden'}, status=403) + + with pytest.raises(RequestHttpError) as exc_info: + client.get('forbidden-endpoint') + assert exc_info.value.status_code == 403 diff --git a/tests/test_models_deserialization.py b/tests/test_models_deserialization.py new file mode 100644 index 00000000..4c7dcd72 --- /dev/null +++ b/tests/test_models_deserialization.py @@ -0,0 +1,451 @@ +from cycode.cyclient.models import ( + ApiToken, + ApiTokenGenerationPollingResponse, + ApiTokenGenerationPollingResponseSchema, + ApiTokenSchema, + AuthenticationSession, + AuthenticationSessionSchema, + ClassificationData, + ClassificationDataSchema, + Detection, + DetectionRule, + DetectionRuleSchema, + DetectionSchema, + Member, + MemberDetails, + MemberSchema, + ReportExecution, + ReportExecutionSchema, + RequestedMemberDetailsResultSchema, + RequestedSbomReportResultSchema, + SbomReport, + SbomReportStorageDetails, + SbomReportStorageDetailsSchema, + ScanConfiguration, + ScanConfigurationSchema, + ScanInitializationResponse, + ScanInitializationResponseSchema, + ScanResult, + ScanResultSchema, + ScanResultsSyncFlow, + ScanResultsSyncFlowSchema, + SupportedModulesPreferences, + SupportedModulesPreferencesSchema, + UserAgentOption, + UserAgentOptionScheme, +) + +# --- DetectionSchema --- + + +def test_detection_schema_load() -> None: + raw = { + 'id': 'det-123', + 'message': 'API key exposed', + 'type': 'secret', + 'severity': 'critical', + 'detection_type_id': 'secret-1', + 'detection_details': {'alert': True, 'value': 'sk_live_xxx'}, + 'detection_rule_id': 'rule-456', + } + result = DetectionSchema().load(raw) + assert isinstance(result, Detection) + assert result.id == 'det-123' + assert result.message == 'API key exposed' + assert result.type == 'secret' + assert result.severity == 'critical' + assert result.detection_type_id == 'secret-1' + assert result.detection_details == {'alert': True, 'value': 'sk_live_xxx'} + assert result.detection_rule_id == 'rule-456' + + +def test_detection_schema_load_defaults() -> None: + raw = { + 'message': 'Vulnerability found', + 'type': 'sca', + 'detection_type_id': 'vuln-1', + 'detection_details': {}, + 'detection_rule_id': 'rule-789', + } + result = DetectionSchema().load(raw) + assert result.id is None + assert result.severity is None + + +def test_detection_schema_excludes_unknown_fields() -> None: + raw = { + 'message': 'Test', + 'type': 'test', + 'detection_type_id': 'test-1', + 'detection_details': {}, + 'detection_rule_id': 'test-rule', + 'unknown_field': 'should_be_ignored', + 'another_unknown': 123, + } + result = DetectionSchema().load(raw) + assert isinstance(result, Detection) + assert not hasattr(result, 'unknown_field') + + +def test_detection_has_alert_true() -> None: + detection = Detection( + detection_type_id='secret-1', + type='secret', + message='Key found', + detection_details={'alert': {'severity': 'high'}}, + detection_rule_id='rule-1', + ) + assert detection.has_alert is True + + +def test_detection_has_alert_false() -> None: + detection = Detection( + detection_type_id='license-1', + type='sca', + message='License issue', + detection_details={'license': 'GPL'}, + detection_rule_id='rule-2', + ) + assert detection.has_alert is False + + +def test_detection_repr() -> None: + detection = Detection( + detection_type_id='secret-1', + type='secret', + message='API key exposed', + detection_details={'value': 'sk_live_xxx'}, + detection_rule_id='rule-1', + severity='critical', + ) + repr_str = repr(detection) + assert 'secret' in repr_str + assert 'critical' in repr_str + assert 'API key exposed' in repr_str + assert 'rule-1' in repr_str + + +# --- ScanResultSchema --- + + +def test_scan_result_schema_load_with_detections() -> None: + raw = { + 'did_detect': True, + 'scan_id': 'scan-abc', + 'detections': [ + { + 'id': 'det-1', + 'message': 'Secret found', + 'type': 'secret', + 'detection_type_id': 'secret-1', + 'detection_details': {'alert': {}}, + 'detection_rule_id': 'rule-1', + } + ], + 'err': '', + } + result = ScanResultSchema().load(raw) + assert isinstance(result, ScanResult) + assert result.did_detect is True + assert result.scan_id == 'scan-abc' + assert len(result.detections) == 1 + assert isinstance(result.detections[0], Detection) + assert result.detections[0].id == 'det-1' + + +def test_scan_result_schema_load_no_detections() -> None: + raw = { + 'did_detect': False, + 'scan_id': 'scan-def', + 'detections': None, + 'err': 'No files to scan', + } + result = ScanResultSchema().load(raw) + assert result.did_detect is False + assert result.detections is None + assert result.err == 'No files to scan' + + +def test_scan_result_schema_excludes_unknown_fields() -> None: + raw = { + 'did_detect': False, + 'scan_id': 'scan-1', + 'detections': None, + 'err': '', + 'extra_field': 'ignored', + } + result = ScanResultSchema().load(raw) + assert isinstance(result, ScanResult) + + +# --- ScanInitializationResponseSchema --- + + +def test_scan_initialization_response_schema_load() -> None: + raw = {'scan_id': 'scan-init-123', 'err': ''} + result = ScanInitializationResponseSchema().load(raw) + assert isinstance(result, ScanInitializationResponse) + assert result.scan_id == 'scan-init-123' + + +# --- AuthenticationSessionSchema --- + + +def test_authentication_session_schema_load() -> None: + raw = {'session_id': 'sess-123'} + result = AuthenticationSessionSchema().load(raw) + assert isinstance(result, AuthenticationSession) + assert result.session_id == 'sess-123' + + +# --- ApiTokenSchema (tests data_key mapping) --- + + +def test_api_token_schema_load_data_key() -> None: + raw = { + 'clientId': 'client-123', + 'secret': 'secret-456', + 'description': 'My API Token', + } + result = ApiTokenSchema().load(raw) + assert isinstance(result, ApiToken) + assert result.client_id == 'client-123' + assert result.secret == 'secret-456' + assert result.description == 'My API Token' + + +# --- ApiTokenGenerationPollingResponseSchema (nested) --- + + +def test_api_token_generation_polling_schema_load() -> None: + raw = { + 'status': 'completed', + 'api_token': { + 'clientId': 'client-abc', + 'secret': 'secret-xyz', + 'description': 'Generated token', + }, + } + result = ApiTokenGenerationPollingResponseSchema().load(raw) + assert isinstance(result, ApiTokenGenerationPollingResponse) + assert result.status == 'completed' + assert isinstance(result.api_token, ApiToken) + assert result.api_token.client_id == 'client-abc' + + +def test_api_token_generation_polling_schema_load_null_token() -> None: + raw = { + 'status': 'pending', + 'api_token': None, + } + result = ApiTokenGenerationPollingResponseSchema().load(raw) + assert result.status == 'pending' + assert result.api_token is None + + +# --- SbomReportStorageDetailsSchema / ReportExecutionSchema / RequestedSbomReportResultSchema --- + + +def test_sbom_report_storage_details_schema_load() -> None: + raw = {'path': '/reports/sbom.json', 'folder': '/reports', 'size': 4096} + result = SbomReportStorageDetailsSchema().load(raw) + assert isinstance(result, SbomReportStorageDetails) + assert result.path == '/reports/sbom.json' + assert result.size == 4096 + + +def test_report_execution_schema_load() -> None: + raw = { + 'id': 1, + 'status': 'completed', + 'error_message': None, + 'status_message': 'Success', + 'storage_details': {'path': '/reports/sbom.json', 'folder': '/reports', 'size': 4096}, + } + result = ReportExecutionSchema().load(raw) + assert isinstance(result, ReportExecution) + assert result.id == 1 + assert result.status == 'completed' + assert isinstance(result.storage_details, SbomReportStorageDetails) + + +def test_requested_sbom_report_result_schema_load() -> None: + raw = { + 'report_executions': [ + { + 'id': 1, + 'status': 'completed', + 'error_message': None, + 'status_message': 'Done', + 'storage_details': {'path': '/r/sbom.json', 'folder': '/r', 'size': 1024}, + }, + { + 'id': 2, + 'status': 'failed', + 'error_message': 'Timeout', + 'status_message': None, + 'storage_details': None, + }, + ] + } + result = RequestedSbomReportResultSchema().load(raw) + assert isinstance(result, SbomReport) + assert len(result.report_executions) == 2 + assert result.report_executions[0].storage_details.path == '/r/sbom.json' + assert result.report_executions[1].error_message == 'Timeout' + assert result.report_executions[1].storage_details is None + + +# --- UserAgentOptionScheme --- + + +def test_user_agent_option_schema_load() -> None: + raw = { + 'app_name': 'vscode_extension', + 'app_version': '0.2.3', + 'env_name': 'Visual Studio Code', + 'env_version': '1.78.2', + } + result = UserAgentOptionScheme().load(raw) + assert isinstance(result, UserAgentOption) + assert result.app_name == 'vscode_extension' + assert 'vscode_extension' in result.user_agent_suffix + assert 'AppVersion: 0.2.3' in result.user_agent_suffix + + +# --- MemberSchema / RequestedMemberDetailsResultSchema --- + + +def test_member_schema_load() -> None: + raw = {'external_id': 'user-ext-123'} + result = MemberSchema().load(raw) + assert isinstance(result, Member) + assert result.external_id == 'user-ext-123' + + +def test_requested_member_details_schema_load() -> None: + raw = { + 'items': [{'external_id': 'u1'}, {'external_id': 'u2'}], + 'page_size': 50, + 'next_page_token': 'token-abc', + } + result = RequestedMemberDetailsResultSchema().load(raw) + assert isinstance(result, MemberDetails) + assert len(result.items) == 2 + assert result.page_size == 50 + assert result.next_page_token == 'token-abc' + + +def test_requested_member_details_schema_load_null_token() -> None: + raw = { + 'items': [], + 'page_size': 50, + 'next_page_token': None, + } + result = RequestedMemberDetailsResultSchema().load(raw) + assert result.next_page_token is None + + +# --- ClassificationDataSchema / DetectionRuleSchema --- + + +def test_classification_data_schema_load() -> None: + raw = {'severity': 'high'} + result = ClassificationDataSchema().load(raw) + assert isinstance(result, ClassificationData) + assert result.severity == 'high' + + +def test_detection_rule_schema_load() -> None: + raw = { + 'classification_data': [{'severity': 'high'}, {'severity': 'medium'}], + 'detection_rule_id': 'rule-123', + 'custom_remediation_guidelines': 'Rotate the key', + 'remediation_guidelines': 'See docs', + 'description': 'Exposed API key', + 'policy_name': 'secrets-policy', + 'display_name': 'API Key Exposure', + } + result = DetectionRuleSchema().load(raw) + assert isinstance(result, DetectionRule) + assert len(result.classification_data) == 2 + assert result.classification_data[0].severity == 'high' + assert result.detection_rule_id == 'rule-123' + assert result.custom_remediation_guidelines == 'Rotate the key' + + +def test_detection_rule_schema_load_optional_nulls() -> None: + raw = { + 'classification_data': [{'severity': 'low'}], + 'detection_rule_id': 'rule-456', + 'custom_remediation_guidelines': None, + 'remediation_guidelines': None, + 'description': None, + 'policy_name': None, + 'display_name': None, + } + result = DetectionRuleSchema().load(raw) + assert result.custom_remediation_guidelines is None + assert result.display_name is None + + +# --- ScanResultsSyncFlowSchema --- + + +def test_scan_results_sync_flow_schema_load() -> None: + raw = { + 'id': 'sync-123', + 'detection_messages': [{'msg': 'found secret'}, {'msg': 'found vuln'}], + } + result = ScanResultsSyncFlowSchema().load(raw) + assert isinstance(result, ScanResultsSyncFlow) + assert result.id == 'sync-123' + assert len(result.detection_messages) == 2 + + +# --- SupportedModulesPreferencesSchema --- + + +def test_supported_modules_preferences_schema_load() -> None: + raw = { + 'secret_scanning': True, + 'leak_scanning': True, + 'iac_scanning': False, + 'sca_scanning': True, + 'ci_cd_scanning': False, + 'sast_scanning': True, + 'container_scanning': False, + 'access_review': True, + 'asoc': False, + 'cimon': True, + 'ai_machine_learning': True, + 'ai_large_language_model': False, + } + result = SupportedModulesPreferencesSchema().load(raw) + assert isinstance(result, SupportedModulesPreferences) + assert result.secret_scanning is True + assert result.iac_scanning is False + assert result.ai_large_language_model is False + + +# --- ScanConfigurationSchema --- + + +def test_scan_configuration_schema_load() -> None: + raw = { + 'scannable_extensions': ['.py', '.js', '.ts'], + 'is_cycode_ignore_allowed': True, + } + result = ScanConfigurationSchema().load(raw) + assert isinstance(result, ScanConfiguration) + assert result.scannable_extensions == ['.py', '.js', '.ts'] + assert result.is_cycode_ignore_allowed is True + + +def test_scan_configuration_schema_load_defaults() -> None: + raw = { + 'scannable_extensions': None, + } + result = ScanConfigurationSchema().load(raw) + assert result.scannable_extensions is None + assert result.is_cycode_ignore_allowed is True # load_default=True From f55dfbedb166168002e4e9e531a135b13de21b28 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 09:37:37 +0000 Subject: [PATCH 239/257] Bump starlette from 0.48.0 to 0.49.1 (#355) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/poetry.lock b/poetry.lock index 807fb2f8..9a11262a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "altgraph" @@ -31,8 +31,7 @@ version = "4.11.0" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.9" -groups = ["main"] -markers = "python_version >= \"3.10\"" +groups = ["main", "dev"] files = [ {file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"}, {file = "anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4"}, @@ -535,12 +534,12 @@ version = "1.3.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["main", "test"] +groups = ["main", "dev", "test"] +markers = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, ] -markers = {main = "python_version == \"3.10\"", test = "python_version < \"3.11\""} [package.dependencies] typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} @@ -664,7 +663,7 @@ version = "3.11" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" -groups = ["main", "test"] +groups = ["main", "dev", "test"] files = [ {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, @@ -1786,8 +1785,7 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" -groups = ["main"] -markers = "python_version >= \"3.10\"" +groups = ["main", "dev"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -1817,15 +1815,14 @@ uvicorn = ["uvicorn (>=0.34.0)"] [[package]] name = "starlette" -version = "0.48.0" +version = "0.49.1" description = "The little ASGI library that shines." optional = false python-versions = ">=3.9" -groups = ["main"] -markers = "python_version >= \"3.10\"" +groups = ["main", "dev"] files = [ - {file = "starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659"}, - {file = "starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46"}, + {file = "starlette-0.49.1-py3-none-any.whl", hash = "sha256:d92ce9f07e4a3caa3ac13a79523bd18e3bc0042bb8ff2d759a8e7dd0e1859875"}, + {file = "starlette-0.49.1.tar.gz", hash = "sha256:481a43b71e24ed8c43b11ea02f5353d77840e01480881b8cb5a26b8cae64a8cb"}, ] [package.dependencies] @@ -1952,12 +1949,12 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["main", "test"] +groups = ["main", "dev", "test"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] -markers = {test = "python_version < \"3.11\""} +markers = {dev = "python_version < \"3.13\"", test = "python_version < \"3.11\""} [[package]] name = "typing-inspection" From 51e89619c07aef6151e4130aed46a19188961d8b Mon Sep 17 00:00:00 2001 From: omerr-cycode Date: Mon, 23 Feb 2026 08:54:28 +0200 Subject: [PATCH 240/257] CM-59965 add additional logging for SCA verbose mode (#392) --- .../sca/base_restore_dependencies.py | 29 ++++++++++++++++++- .../sca/go/restore_go_dependencies.py | 4 ++- .../files_collector/sca/sca_file_collector.py | 17 +++++++++-- cycode/cli/utils/shell_executor.py | 17 +++++++++-- 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/cycode/cli/files_collector/sca/base_restore_dependencies.py b/cycode/cli/files_collector/sca/base_restore_dependencies.py index 80ef4183..7e69a0d9 100644 --- a/cycode/cli/files_collector/sca/base_restore_dependencies.py +++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py @@ -7,6 +7,9 @@ from cycode.cli.models import Document from cycode.cli.utils.path_utils import get_file_content, get_file_dir, get_path_from_context, join_paths from cycode.cli.utils.shell_executor import shell +from cycode.logger import get_logger + +logger = get_logger('SCA Restore') def build_dep_tree_path(path: str, generated_file_name: str) -> str: @@ -19,6 +22,16 @@ def execute_commands( output_file_path: Optional[str] = None, working_directory: Optional[str] = None, ) -> Optional[str]: + logger.debug( + 'Executing restore commands, %s', + { + 'commands_count': len(commands), + 'timeout_sec': timeout, + 'working_directory': working_directory, + 'output_file_path': output_file_path, + }, + ) + try: outputs = [] @@ -32,7 +45,8 @@ def execute_commands( if output_file_path: with open(output_file_path, 'w', encoding='UTF-8') as output_file: output_file.writelines(joined_output) - except Exception: + except Exception as e: + logger.debug('Unexpected error during command execution', exc_info=e) return None return joined_output @@ -75,8 +89,21 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: ) if output is None: # one of the commands failed return None + else: + logger.debug( + 'Lock file already exists, skipping restore commands, %s', + {'restore_file_path': restore_file_path}, + ) restore_file_content = get_file_content(restore_file_path) + logger.debug( + 'Restore file loaded, %s', + { + 'restore_file_path': restore_file_path, + 'content_size': len(restore_file_content) if restore_file_content else 0, + 'content_empty': not restore_file_content, + }, + ) return Document(relative_restore_file_path, restore_file_content, self.is_git_diff) def get_working_directory(self, document: Document) -> Optional[str]: diff --git a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py index 7c24e330..fc94eb03 100644 --- a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py +++ b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py @@ -4,8 +4,10 @@ import typer from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies -from cycode.cli.logger import logger from cycode.cli.models import Document +from cycode.logger import get_logger + +logger = get_logger('Go Restore Dependencies') GO_PROJECT_FILE_EXTENSIONS = ['.mod', '.sum'] GO_RESTORE_FILE_NAME = 'go.mod.graph' diff --git a/cycode/cli/files_collector/sca/sca_file_collector.py b/cycode/cli/files_collector/sca/sca_file_collector.py index 41f70316..801b5d6f 100644 --- a/cycode/cli/files_collector/sca/sca_file_collector.py +++ b/cycode/cli/files_collector/sca/sca_file_collector.py @@ -106,11 +106,17 @@ def _try_restore_dependencies( restore_dependencies_document = restore_dependencies.restore(document) if restore_dependencies_document is None: - logger.warning('Error occurred while trying to generate dependencies tree, %s', {'filename': document.path}) + logger.warning( + 'Error occurred while trying to generate dependencies tree, %s', + {'filename': document.path, 'handler': type(restore_dependencies).__name__}, + ) return None if restore_dependencies_document.content is None: - logger.warning('Error occurred while trying to generate dependencies tree, %s', {'filename': document.path}) + logger.warning( + 'Error occurred while trying to generate dependencies tree, %s', + {'filename': document.path, 'handler': type(restore_dependencies).__name__}, + ) restore_dependencies_document.content = '' else: is_monitor_action = ctx.obj.get('monitor', False) @@ -124,6 +130,13 @@ def _try_restore_dependencies( def _get_restore_handlers(ctx: typer.Context, is_git_diff: bool) -> list[BaseRestoreDependencies]: build_dep_tree_timeout = int(os.getenv('CYCODE_BUILD_DEP_TREE_TIMEOUT_SECONDS', BUILD_DEP_TREE_TIMEOUT)) + logger.debug( + 'SCA restore handler timeout, %s', + { + 'timeout_sec': build_dep_tree_timeout, + 'source': 'env' if os.getenv('CYCODE_BUILD_DEP_TREE_TIMEOUT_SECONDS') else 'default', + }, + ) return [ RestoreGradleDependencies(ctx, is_git_diff, build_dep_tree_timeout), RestoreMavenDependencies(ctx, is_git_diff, build_dep_tree_timeout), diff --git a/cycode/cli/utils/shell_executor.py b/cycode/cli/utils/shell_executor.py index 2529890b..b39d2a0b 100644 --- a/cycode/cli/utils/shell_executor.py +++ b/cycode/cli/utils/shell_executor.py @@ -1,4 +1,5 @@ import subprocess +import time from typing import Optional, Union import click @@ -21,15 +22,27 @@ def shell( logger.debug('Executing shell command: %s', command) try: + start = time.monotonic() result = subprocess.run( # noqa: S603 command, cwd=working_directory, timeout=timeout, check=True, capture_output=True ) - logger.debug('Shell command executed successfully') + duration_sec = round(time.monotonic() - start, 2) + stdout = result.stdout.decode('UTF-8').strip() + stderr = result.stderr.decode('UTF-8').strip() - return result.stdout.decode('UTF-8').strip() + logger.debug( + 'Shell command executed successfully, %s', + {'duration_sec': duration_sec, 'stdout': stdout if stdout else '', 'stderr': stderr if stderr else ''}, + ) + + return stdout except subprocess.CalledProcessError as e: if not silent_exc_info: logger.debug('Error occurred while running shell command', exc_info=e) + if e.stdout: + logger.debug('Shell command stdout: %s', e.stdout.decode('UTF-8').strip()) + if e.stderr: + logger.debug('Shell command stderr: %s', e.stderr.decode('UTF-8').strip()) except subprocess.TimeoutExpired as e: logger.debug('Command timed out', exc_info=e) raise typer.Abort(f'Command "{command}" timed out') from e From 457022c99a6e927ae63d1fe0967b2ee9e2464991 Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Mon, 23 Feb 2026 08:36:13 +0000 Subject: [PATCH 241/257] CM-53930: improve notarization output (#391) --- .github/workflows/build_executable.yml | 35 ++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 333427a3..1f1e2582 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -127,6 +127,22 @@ jobs: - name: Test executable run: time $PATH_TO_CYCODE_CLI_EXECUTABLE version + - name: Codesign onedir binaries + if: runner.os == 'macOS' && matrix.mode == 'onedir' + env: + APPLE_CERT_NAME: ${{ secrets.APPLE_CERT_NAME }} + run: | + # Sign all Mach-O binaries in the onedir output (excluding the main executable) + # Main executable must be signed last after all its dependencies + find dist/cycode-cli -type f ! -name "cycode-cli" | while read -r file; do + if file -b "$file" | grep -q "Mach-O"; then + codesign --force --sign "$APPLE_CERT_NAME" --timestamp --options runtime "$file" + fi + done + + # Re-sign the main executable with entitlements (must be last) + codesign --force --sign "$APPLE_CERT_NAME" --timestamp --options runtime --entitlements entitlements.plist dist/cycode-cli/cycode-cli + - name: Notarize macOS executable if: runner.os == 'macOS' env: @@ -137,11 +153,26 @@ jobs: # create keychain profile xcrun notarytool store-credentials "notarytool-profile" --apple-id "$APPLE_NOTARIZATION_EMAIL" --team-id "$APPLE_NOTARIZATION_TEAM_ID" --password "$APPLE_NOTARIZATION_PWD" - # create zip file (notarization does not support binaries) + # create zip file (notarization does not support bare binaries) ditto -c -k --keepParent dist/cycode-cli notarization.zip # notarize app (this will take a while) - xcrun notarytool submit notarization.zip --keychain-profile "notarytool-profile" --wait + NOTARIZE_OUTPUT=$(xcrun notarytool submit notarization.zip --keychain-profile "notarytool-profile" --wait 2>&1) || true + echo "$NOTARIZE_OUTPUT" + + # extract submission ID for log retrieval + SUBMISSION_ID=$(echo "$NOTARIZE_OUTPUT" | grep " id:" | head -1 | awk '{print $2}') + + # check notarization status explicitly + if echo "$NOTARIZE_OUTPUT" | grep -q "status: Accepted"; then + echo "Notarization succeeded!" + else + echo "Notarization failed! Fetching log for details..." + if [ -n "$SUBMISSION_ID" ]; then + xcrun notarytool log "$SUBMISSION_ID" --keychain-profile "notarytool-profile" || true + fi + exit 1 + fi # we can't staple the app because it's executable From 8e4450c70e8fa1fdef57019035b169cbafc20306 Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Tue, 24 Feb 2026 09:14:27 +0000 Subject: [PATCH 242/257] CM-53930: fix onedir signing issues on mac (#394) --- .github/workflows/build_executable.yml | 95 +++++++++++++++++++++++--- process_executable_file.py | 6 +- 2 files changed, 90 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 1f1e2582..6749ca79 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -125,20 +125,34 @@ jobs: echo "PATH_TO_CYCODE_CLI_EXECUTABLE=dist/cycode-cli/cycode-cli" >> $GITHUB_ENV - name: Test executable - run: time $PATH_TO_CYCODE_CLI_EXECUTABLE version + run: time $PATH_TO_CYCODE_CLI_EXECUTABLE status - name: Codesign onedir binaries if: runner.os == 'macOS' && matrix.mode == 'onedir' env: APPLE_CERT_NAME: ${{ secrets.APPLE_CERT_NAME }} run: | - # Sign all Mach-O binaries in the onedir output (excluding the main executable) - # Main executable must be signed last after all its dependencies - find dist/cycode-cli -type f ! -name "cycode-cli" | while read -r file; do + # The standalone _internal/Python fails codesign --verify --strict because it was + # extracted from Python.framework without Info.plist context. + # Fix: remove the bare copy and replace with the framework version's binary, + # then delete the framework directory (it's redundant). + if [ -d dist/cycode-cli/_internal/Python.framework ]; then + FRAMEWORK_PYTHON=$(find dist/cycode-cli/_internal/Python.framework/Versions -name "Python" -type f | head -1) + if [ -n "$FRAMEWORK_PYTHON" ]; then + echo "Replacing _internal/Python with framework binary" + rm dist/cycode-cli/_internal/Python + cp "$FRAMEWORK_PYTHON" dist/cycode-cli/_internal/Python + fi + rm -rf dist/cycode-cli/_internal/Python.framework + fi + + # Sign all Mach-O binaries (excluding the main executable) + while IFS= read -r file; do if file -b "$file" | grep -q "Mach-O"; then + echo "Signing: $file" codesign --force --sign "$APPLE_CERT_NAME" --timestamp --options runtime "$file" fi - done + done < <(find dist/cycode-cli -type f ! -name "cycode-cli") # Re-sign the main executable with entitlements (must be last) codesign --force --sign "$APPLE_CERT_NAME" --timestamp --options runtime --entitlements entitlements.plist dist/cycode-cli/cycode-cli @@ -176,15 +190,35 @@ jobs: # we can't staple the app because it's executable - - name: Test macOS signed executable + - name: Verify macOS code signatures if: runner.os == 'macOS' run: | - file -b $PATH_TO_CYCODE_CLI_EXECUTABLE - time $PATH_TO_CYCODE_CLI_EXECUTABLE version + FAILED=false + while IFS= read -r file; do + if file -b "$file" | grep -q "Mach-O"; then + if ! codesign --verify "$file" 2>&1; then + echo "INVALID: $file" + codesign -dv "$file" 2>&1 || true + FAILED=true + else + echo "OK: $file" + fi + fi + done < <(find dist/cycode-cli -type f) + + if [ "$FAILED" = true ]; then + echo "Found binaries with invalid signatures!" + exit 1 + fi - # verify signature codesign -dv --verbose=4 $PATH_TO_CYCODE_CLI_EXECUTABLE + - name: Test macOS signed executable + if: runner.os == 'macOS' + run: | + file -b $PATH_TO_CYCODE_CLI_EXECUTABLE + time $PATH_TO_CYCODE_CLI_EXECUTABLE status + - name: Import cert for Windows and setup envs if: runner.os == 'Windows' env: @@ -222,7 +256,7 @@ jobs: shell: cmd run: | :: call executable and expect correct output - .\dist\cycode-cli.exe version + .\dist\cycode-cli.exe status :: verify signature signtool.exe verify /v /pa ".\dist\cycode-cli.exe" @@ -236,6 +270,47 @@ jobs: name: ${{ env.ARTIFACT_NAME }} path: dist + - name: Verify macOS artifact end-to-end + if: runner.os == 'macOS' && matrix.mode == 'onedir' + uses: actions/download-artifact@v4 + with: + name: ${{ env.ARTIFACT_NAME }} + path: /tmp/artifact-verify + + - name: Verify macOS artifact signatures and run with quarantine + if: runner.os == 'macOS' && matrix.mode == 'onedir' + run: | + # extract the onedir zip exactly as an end user would + ARCHIVE=$(find /tmp/artifact-verify -name "*.zip" | head -1) + echo "Verifying archive: $ARCHIVE" + unzip "$ARCHIVE" -d /tmp/artifact-extracted + + # verify all Mach-O code signatures + FAILED=false + while IFS= read -r file; do + if file -b "$file" | grep -q "Mach-O"; then + if ! codesign --verify "$file" 2>&1; then + echo "INVALID: $file" + codesign -dv "$file" 2>&1 || true + FAILED=true + else + echo "OK: $file" + fi + fi + done < <(find /tmp/artifact-extracted -type f) + + if [ "$FAILED" = true ]; then + echo "Artifact contains binaries with invalid signatures!" + exit 1 + fi + + # simulate download quarantine and test execution + # this is the definitive test — it triggers the same dlopen checks end users experience + find /tmp/artifact-extracted -type f -exec xattr -w com.apple.quarantine "0081;$(printf '%x' $(date +%s));CI;$(uuidgen)" {} \; + EXECUTABLE=$(find /tmp/artifact-extracted -name "cycode-cli" -type f | head -1) + echo "Testing quarantined executable: $EXECUTABLE" + time "$EXECUTABLE" status + - name: Upload files to release if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish }} uses: svenstaro/upload-release-action@v2 diff --git a/process_executable_file.py b/process_executable_file.py index 367bb18d..36d6d0d6 100755 --- a/process_executable_file.py +++ b/process_executable_file.py @@ -140,6 +140,10 @@ def get_cli_archive_path(output_path: Path, is_onedir: bool) -> str: return os.path.join(output_path, get_cli_archive_filename(is_onedir)) +def archive_directory(input_path: Path, output_path: str) -> None: + shutil.make_archive(output_path.removesuffix(f'.{_ARCHIVE_FORMAT}'), _ARCHIVE_FORMAT, input_path) + + def process_executable_file(input_path: Path, is_onedir: bool) -> str: output_path = input_path.parent hash_file_path = get_cli_hash_path(output_path, is_onedir) @@ -150,7 +154,7 @@ def process_executable_file(input_path: Path, is_onedir: bool) -> str: write_hashes_db_to_file(normalized_hashes, hash_file_path) archived_file_path = get_cli_archive_path(output_path, is_onedir) - shutil.make_archive(archived_file_path, _ARCHIVE_FORMAT, input_path) + archive_directory(input_path, f'{archived_file_path}.{_ARCHIVE_FORMAT}') shutil.rmtree(input_path) else: file_hash = get_hash_of_file(input_path) From 718521abcea69720f4c419c5f4cb3e0c4fb1fff8 Mon Sep 17 00:00:00 2001 From: omerr-cycode Date: Mon, 2 Mar 2026 11:56:23 +0200 Subject: [PATCH 243/257] CM-59977: SCA maintainability improvements (#393) --- README.md | 44 ++- .../cli/apps/report/sbom/path/path_command.py | 25 +- cycode/cli/apps/sca_options.py | 47 +++ cycode/cli/apps/scan/scan_command.py | 40 +- cycode/cli/cli_types.py | 1 + .../sca/base_restore_dependencies.py | 32 +- .../sca/go/restore_go_dependencies.py | 8 +- .../sca/npm/restore_deno_dependencies.py | 46 +++ .../sca/npm/restore_npm_dependencies.py | 159 ++------ .../sca/npm/restore_pnpm_dependencies.py | 70 ++++ .../sca/npm/restore_yarn_dependencies.py | 70 ++++ .../cli/files_collector/sca/php/__init__.py | 0 .../sca/php/restore_composer_dependencies.py | 54 +++ .../files_collector/sca/python/__init__.py | 0 .../sca/python/restore_pipenv_dependencies.py | 45 +++ .../sca/python/restore_poetry_dependencies.py | 62 +++ .../files_collector/sca/sca_file_collector.py | 14 +- .../sca/npm/test_restore_deno_dependencies.py | 65 ++++ .../sca/npm/test_restore_npm_dependencies.py | 362 ++++-------------- .../sca/npm/test_restore_pnpm_dependencies.py | 91 +++++ .../sca/npm/test_restore_yarn_dependencies.py | 91 +++++ tests/cli/files_collector/sca/php/__init__.py | 0 .../php/test_restore_composer_dependencies.py | 82 ++++ .../files_collector/sca/python/__init__.py | 0 .../test_restore_pipenv_dependencies.py | 73 ++++ .../test_restore_poetry_dependencies.py | 99 +++++ 26 files changed, 1081 insertions(+), 499 deletions(-) create mode 100644 cycode/cli/apps/sca_options.py create mode 100644 cycode/cli/files_collector/sca/npm/restore_deno_dependencies.py create mode 100644 cycode/cli/files_collector/sca/npm/restore_pnpm_dependencies.py create mode 100644 cycode/cli/files_collector/sca/npm/restore_yarn_dependencies.py create mode 100644 cycode/cli/files_collector/sca/php/__init__.py create mode 100644 cycode/cli/files_collector/sca/php/restore_composer_dependencies.py create mode 100644 cycode/cli/files_collector/sca/python/__init__.py create mode 100644 cycode/cli/files_collector/sca/python/restore_pipenv_dependencies.py create mode 100644 cycode/cli/files_collector/sca/python/restore_poetry_dependencies.py create mode 100644 tests/cli/files_collector/sca/npm/test_restore_deno_dependencies.py create mode 100644 tests/cli/files_collector/sca/npm/test_restore_pnpm_dependencies.py create mode 100644 tests/cli/files_collector/sca/npm/test_restore_yarn_dependencies.py create mode 100644 tests/cli/files_collector/sca/php/__init__.py create mode 100644 tests/cli/files_collector/sca/php/test_restore_composer_dependencies.py create mode 100644 tests/cli/files_collector/sca/python/__init__.py create mode 100644 tests/cli/files_collector/sca/python/test_restore_pipenv_dependencies.py create mode 100644 tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py diff --git a/README.md b/README.md index 2abfd3b2..b512c813 100644 --- a/README.md +++ b/README.md @@ -668,15 +668,33 @@ In the previous example, if you wanted to only scan a branch named `dev`, you co > [!NOTE] > This option is only available to SCA scans. -We use the sbt-dependency-lock plugin to restore the lock file for SBT projects. -To disable lock restore in use `--no-restore` option. - -Prerequisites: -* `sbt-dependency-lock` plugin: Install the plugin by adding the following line to `project/plugins.sbt`: - - ```text - addSbtPlugin("software.purpledragon" % "sbt-dependency-lock" % "1.5.1") - ``` +When running an SCA scan, Cycode CLI automatically attempts to restore (generate) a dependency lockfile for each supported manifest file it finds. This allows scanning transitive dependencies, not just the ones listed directly in the manifest. To skip this step and scan only direct dependencies, use the `--no-restore` flag. + +The following ecosystems support automatic lockfile restoration: + +| Ecosystem | Manifest file | Lockfile generated | Tool invoked (when lockfile is absent) | +|---|---|---|---| +| npm | `package.json` | `package-lock.json` | `npm install --package-lock-only --ignore-scripts --no-audit` | +| Yarn | `package.json` | `yarn.lock` | `yarn install --ignore-scripts` | +| pnpm | `package.json` | `pnpm-lock.yaml` | `pnpm install --ignore-scripts` | +| Deno | `deno.json` / `deno.jsonc` | `deno.lock` | *(read existing lockfile only)* | +| Go | `go.mod` | `go.mod.graph` | `go list -m -json all` + `go mod graph` | +| Maven | `pom.xml` | `bcde.mvndeps` | `mvn dependency:tree` | +| Gradle | `build.gradle` / `build.gradle.kts` | `gradle-dependencies-generated.txt` | `gradle dependencies -q --console plain` | +| SBT | `build.sbt` | `build.sbt.lock` | `sbt dependencyLockWrite` | +| NuGet | `*.csproj` | `packages.lock.json` | `dotnet restore --use-lock-file` | +| Ruby | `Gemfile` | `Gemfile.lock` | `bundle --quiet` | +| Poetry | `pyproject.toml` | `poetry.lock` | `poetry lock` | +| Pipenv | `Pipfile` | `Pipfile.lock` | `pipenv lock` | +| PHP Composer | `composer.json` | `composer.lock` | `composer update --no-cache --no-install --no-scripts --ignore-platform-reqs` | + +If a lockfile already exists alongside the manifest, Cycode reads it directly without running any install command. + +**SBT prerequisite:** The `sbt-dependency-lock` plugin must be installed. Add the following line to `project/plugins.sbt`: + +```text +addSbtPlugin("software.purpledragon" % "sbt-dependency-lock" % "1.5.1") +``` ### Repository Scan @@ -1309,9 +1327,11 @@ For example:\ The `path` subcommand supports the following additional options: -| Option | Description | -|-------------------------|----------------------------------------------------------------------------------------------------------------------------------| -| `--maven-settings-file` | For Maven only, allows using a custom [settings.xml](https://maven.apache.org/settings.html) file when building the dependency tree | +| Option | Description | +|-----------------------------|-------------------------------------------------------------------------------------------------------------------------------------| +| `--no-restore` | Skip lockfile restoration and scan direct dependencies only. See [Lock Restore Option](#lock-restore-option) for details. | +| `--gradle-all-sub-projects` | Run the Gradle restore command for all sub-projects (use from the root of a multi-project Gradle build). | +| `--maven-settings-file` | For Maven only, allows using a custom [settings.xml](https://maven.apache.org/settings.html) file when building the dependency tree. | # Import Command diff --git a/cycode/cli/apps/report/sbom/path/path_command.py b/cycode/cli/apps/report/sbom/path/path_command.py index a127bfc7..a3ffa578 100644 --- a/cycode/cli/apps/report/sbom/path/path_command.py +++ b/cycode/cli/apps/report/sbom/path/path_command.py @@ -1,11 +1,17 @@ import time from pathlib import Path -from typing import Annotated, Optional +from typing import Annotated import typer from cycode.cli import consts from cycode.cli.apps.report.sbom.common import create_sbom_report, send_report_feedback +from cycode.cli.apps.sca_options import ( + GradleAllSubProjectsOption, + MavenSettingsFileOption, + NoRestoreOption, + apply_sca_restore_options_to_context, +) from cycode.cli.exceptions.handle_report_sbom_errors import handle_report_exception from cycode.cli.files_collector.path_documents import get_relevant_documents from cycode.cli.files_collector.sca.sca_file_collector import add_sca_dependencies_tree_documents_if_needed @@ -14,8 +20,6 @@ from cycode.cli.utils.progress_bar import SbomReportProgressBarSection from cycode.cli.utils.scan_utils import is_cycodeignore_allowed_by_scan_config -_SCA_RICH_HELP_PANEL = 'SCA options' - def path_command( ctx: typer.Context, @@ -23,18 +27,11 @@ def path_command( Path, typer.Argument(exists=True, resolve_path=True, help='Path to generate SBOM report for.', show_default=False), ], - maven_settings_file: Annotated[ - Optional[Path], - typer.Option( - '--maven-settings-file', - show_default=False, - help='When specified, Cycode will use this settings.xml file when building the maven dependency tree.', - dir_okay=False, - rich_help_panel=_SCA_RICH_HELP_PANEL, - ), - ] = None, + no_restore: NoRestoreOption = False, + gradle_all_sub_projects: GradleAllSubProjectsOption = False, + maven_settings_file: MavenSettingsFileOption = None, ) -> None: - ctx.obj['maven_settings_file'] = maven_settings_file + apply_sca_restore_options_to_context(ctx, no_restore, gradle_all_sub_projects, maven_settings_file) client = get_report_cycode_client(ctx) report_parameters = ctx.obj['report_parameters'] diff --git a/cycode/cli/apps/sca_options.py b/cycode/cli/apps/sca_options.py new file mode 100644 index 00000000..3c904ee6 --- /dev/null +++ b/cycode/cli/apps/sca_options.py @@ -0,0 +1,47 @@ +from pathlib import Path +from typing import Annotated, Optional + +import typer + +_SCA_RICH_HELP_PANEL = 'SCA options' + +NoRestoreOption = Annotated[ + bool, + typer.Option( + '--no-restore', + help='When specified, Cycode will not run restore command. Will scan direct dependencies [b]only[/]!', + rich_help_panel=_SCA_RICH_HELP_PANEL, + ), +] + +GradleAllSubProjectsOption = Annotated[ + bool, + typer.Option( + '--gradle-all-sub-projects', + help='When specified, Cycode will run gradle restore command for all sub projects. ' + 'Should run from root project directory [b]only[/]!', + rich_help_panel=_SCA_RICH_HELP_PANEL, + ), +] + +MavenSettingsFileOption = Annotated[ + Optional[Path], + typer.Option( + '--maven-settings-file', + show_default=False, + help='When specified, Cycode will use this settings.xml file when building the maven dependency tree.', + dir_okay=False, + rich_help_panel=_SCA_RICH_HELP_PANEL, + ), +] + + +def apply_sca_restore_options_to_context( + ctx: typer.Context, + no_restore: bool, + gradle_all_sub_projects: bool, + maven_settings_file: Optional[Path], +) -> None: + ctx.obj['no_restore'] = no_restore + ctx.obj['gradle_all_sub_projects'] = gradle_all_sub_projects + ctx.obj['maven_settings_file'] = maven_settings_file diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 9892f1b6..7aab9d27 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -5,6 +5,12 @@ import click import typer +from cycode.cli.apps.sca_options import ( + GradleAllSubProjectsOption, + MavenSettingsFileOption, + NoRestoreOption, + apply_sca_restore_options_to_context, +) from cycode.cli.apps.scan.remote_url_resolver import _try_get_git_remote_url from cycode.cli.cli_types import ExportTypeOption, ScanTypeOption, ScaScanTypeOption, SeverityOption from cycode.cli.consts import ( @@ -72,33 +78,9 @@ def scan_command( rich_help_panel=_SCA_RICH_HELP_PANEL, ), ] = False, - no_restore: Annotated[ - bool, - typer.Option( - '--no-restore', - help='When specified, Cycode will not run restore command. Will scan direct dependencies [b]only[/]!', - rich_help_panel=_SCA_RICH_HELP_PANEL, - ), - ] = False, - gradle_all_sub_projects: Annotated[ - bool, - typer.Option( - '--gradle-all-sub-projects', - help='When specified, Cycode will run gradle restore command for all sub projects. ' - 'Should run from root project directory [b]only[/]!', - rich_help_panel=_SCA_RICH_HELP_PANEL, - ), - ] = False, - maven_settings_file: Annotated[ - Optional[Path], - typer.Option( - '--maven-settings-file', - show_default=False, - help='When specified, Cycode will use this settings.xml file when building the maven dependency tree.', - dir_okay=False, - rich_help_panel=_SCA_RICH_HELP_PANEL, - ), - ] = None, + no_restore: NoRestoreOption = False, + gradle_all_sub_projects: GradleAllSubProjectsOption = False, + maven_settings_file: MavenSettingsFileOption = None, export_type: Annotated[ ExportTypeOption, typer.Option( @@ -152,10 +134,8 @@ def scan_command( ctx.obj['sync'] = sync ctx.obj['severity_threshold'] = severity_threshold ctx.obj['monitor'] = monitor - ctx.obj['maven_settings_file'] = maven_settings_file ctx.obj['report'] = report - ctx.obj['gradle_all_sub_projects'] = gradle_all_sub_projects - ctx.obj['no_restore'] = no_restore + apply_sca_restore_options_to_context(ctx, no_restore, gradle_all_sub_projects, maven_settings_file) scan_client = get_scan_cycode_client(ctx) ctx.obj['client'] = scan_client diff --git a/cycode/cli/cli_types.py b/cycode/cli/cli_types.py index bd88faea..ed277cc6 100644 --- a/cycode/cli/cli_types.py +++ b/cycode/cli/cli_types.py @@ -46,6 +46,7 @@ class SbomFormatOption(StrEnum): SPDX_2_2 = 'spdx-2.2' SPDX_2_3 = 'spdx-2.3' CYCLONEDX_1_4 = 'cyclonedx-1.4' + CYCLONEDX_1_6 = 'cyclonedx-1.6' class SbomOutputFormatOption(StrEnum): diff --git a/cycode/cli/files_collector/sca/base_restore_dependencies.py b/cycode/cli/files_collector/sca/base_restore_dependencies.py index 7e69a0d9..ac391727 100644 --- a/cycode/cli/files_collector/sca/base_restore_dependencies.py +++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py @@ -1,5 +1,5 @@ -import os from abc import ABC, abstractmethod +from pathlib import Path from typing import Optional import typer @@ -32,6 +32,9 @@ def execute_commands( }, ) + if not commands: + return None + try: outputs = [] @@ -106,22 +109,43 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: ) return Document(relative_restore_file_path, restore_file_content, self.is_git_diff) + def get_manifest_dir(self, document: Document) -> Optional[str]: + """Return the directory containing the manifest file, resolving monitor-mode paths. + + Uses the same path resolution as get_manifest_file_path() to ensure consistency. + Falls back to document.absolute_path when the resolved manifest path is ambiguous. + """ + manifest_file_path = self.get_manifest_file_path(document) + if manifest_file_path: + parent = Path(manifest_file_path).parent + # Skip '.' (no parent) and filesystem root (its own parent) + if parent != Path('.') and parent != parent.parent: + return str(parent) + + base = document.absolute_path or document.path + if base: + parent = Path(base).parent + if parent != Path('.') and parent != parent.parent: + return str(parent) + + return None + def get_working_directory(self, document: Document) -> Optional[str]: - return os.path.dirname(document.absolute_path) + return str(Path(document.absolute_path).parent) def get_restored_lock_file_name(self, restore_file_path: str) -> str: return self.get_lock_file_name() def get_any_restore_file_already_exist(self, document: Document, restore_file_paths: list[str]) -> str: for restore_file_path in restore_file_paths: - if os.path.isfile(restore_file_path): + if Path(restore_file_path).is_file(): return restore_file_path return build_dep_tree_path(document.absolute_path, self.get_lock_file_name()) @staticmethod def verify_restore_file_already_exist(restore_file_path: str) -> bool: - return os.path.isfile(restore_file_path) + return Path(restore_file_path).is_file() @abstractmethod def is_project(self, document: Document) -> bool: diff --git a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py index fc94eb03..b98fbaf5 100644 --- a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py +++ b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py @@ -1,4 +1,4 @@ -import os +from pathlib import Path from typing import Optional import typer @@ -20,13 +20,13 @@ def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) super().__init__(ctx, is_git_diff, command_timeout, create_output_file_manually=True) def try_restore_dependencies(self, document: Document) -> Optional[Document]: - manifest_exists = os.path.isfile(self.get_working_directory(document) + os.sep + BUILD_GO_FILE_NAME) - lock_exists = os.path.isfile(self.get_working_directory(document) + os.sep + BUILD_GO_LOCK_FILE_NAME) + manifest_exists = (Path(self.get_working_directory(document)) / BUILD_GO_FILE_NAME).is_file() + lock_exists = (Path(self.get_working_directory(document)) / BUILD_GO_LOCK_FILE_NAME).is_file() if not manifest_exists or not lock_exists: logger.info('No manifest go.mod file found' if not manifest_exists else 'No manifest go.sum file found') - manifest_files_exists = manifest_exists & lock_exists + manifest_files_exists = manifest_exists and lock_exists if not manifest_files_exists: return None diff --git a/cycode/cli/files_collector/sca/npm/restore_deno_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_deno_dependencies.py new file mode 100644 index 00000000..d3aeb5e5 --- /dev/null +++ b/cycode/cli/files_collector/sca/npm/restore_deno_dependencies.py @@ -0,0 +1,46 @@ +from pathlib import Path +from typing import Optional + +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.logger import get_logger + +logger = get_logger('Deno Restore Dependencies') + +DENO_MANIFEST_FILE_NAMES = ('deno.json', 'deno.jsonc') +DENO_LOCK_FILE_NAME = 'deno.lock' + + +class RestoreDenoDependencies(BaseRestoreDependencies): + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + return Path(document.path).name in DENO_MANIFEST_FILE_NAMES + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + if not manifest_dir: + return None + + lockfile_path = Path(manifest_dir) / DENO_LOCK_FILE_NAME + if not lockfile_path.is_file(): + logger.debug('No deno.lock found alongside deno.json, skipping deno restore, %s', {'path': document.path}) + return None + + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, DENO_LOCK_FILE_NAME) + logger.debug('Using existing deno.lock, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [] + + def get_lock_file_name(self) -> str: + return DENO_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [DENO_LOCK_FILE_NAME] diff --git a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py index 9f8c0b66..d07bc4a5 100644 --- a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py +++ b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py @@ -1,21 +1,17 @@ -import os -from typing import Optional +from pathlib import Path import typer -from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies from cycode.cli.models import Document -from cycode.cli.utils.path_utils import get_file_content from cycode.logger import get_logger logger = get_logger('NPM Restore Dependencies') -NPM_PROJECT_FILE_EXTENSIONS = ['.json'] -NPM_LOCK_FILE_NAME = 'package-lock.json' -# Alternative lockfiles that should prevent npm install from running -ALTERNATIVE_LOCK_FILES = ['yarn.lock', 'pnpm-lock.yaml', 'deno.lock'] -NPM_LOCK_FILE_NAMES = [NPM_LOCK_FILE_NAME, *ALTERNATIVE_LOCK_FILES] NPM_MANIFEST_FILE_NAME = 'package.json' +NPM_LOCK_FILE_NAME = 'package-lock.json' +# These lockfiles indicate another package manager owns the project — NPM should not run +_ALTERNATIVE_LOCK_FILES = ('yarn.lock', 'pnpm-lock.yaml', 'deno.lock') class RestoreNpmDependencies(BaseRestoreDependencies): @@ -23,128 +19,25 @@ def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) super().__init__(ctx, is_git_diff, command_timeout) def is_project(self, document: Document) -> bool: - return any(document.path.endswith(ext) for ext in NPM_PROJECT_FILE_EXTENSIONS) - - def _resolve_manifest_directory(self, document: Document) -> Optional[str]: - """Resolve the directory containing the manifest file. - - Uses the same path resolution logic as get_manifest_file_path() to ensure consistency. - Falls back to absolute_path or document.path if needed. - - Returns: - Directory path if resolved, None otherwise. - """ - manifest_file_path = self.get_manifest_file_path(document) - manifest_dir = os.path.dirname(manifest_file_path) if manifest_file_path else None - - # Fallback: if manifest_dir is empty or root, try using absolute_path or document.path - if not manifest_dir or manifest_dir == os.sep or manifest_dir == '.': - base_path = document.absolute_path if document.absolute_path else document.path - if base_path: - manifest_dir = os.path.dirname(base_path) + """Match only package.json files that are not managed by Yarn or pnpm. - return manifest_dir - - def _find_existing_lockfile(self, manifest_dir: str) -> tuple[Optional[str], list[str]]: - """Find the first existing lockfile in the manifest directory. - - Args: - manifest_dir: Directory to search for lockfiles. - - Returns: - Tuple of (lockfile_path if found, list of checked lockfiles with status). + Yarn and pnpm projects are handled by their dedicated handlers, which run before + this one in the handler list. This handler is the npm fallback. """ - lock_file_paths = [os.path.join(manifest_dir, lock_file_name) for lock_file_name in NPM_LOCK_FILE_NAMES] - - existing_lock_file = None - checked_lockfiles = [] - for lock_file_path in lock_file_paths: - lock_file_name = os.path.basename(lock_file_path) - exists = os.path.isfile(lock_file_path) - checked_lockfiles.append(f'{lock_file_name}: {"exists" if exists else "not found"}') - if exists: - existing_lock_file = lock_file_path - break + if Path(document.path).name != NPM_MANIFEST_FILE_NAME: + return False - return existing_lock_file, checked_lockfiles + manifest_dir = self.get_manifest_dir(document) + if manifest_dir: + for lock_file in _ALTERNATIVE_LOCK_FILES: + if (Path(manifest_dir) / lock_file).is_file(): + logger.debug( + 'Skipping npm restore: alternative lockfile detected, %s', + {'path': document.path, 'lockfile': lock_file}, + ) + return False - def _create_document_from_lockfile(self, document: Document, lockfile_path: str) -> Optional[Document]: - """Create a Document from an existing lockfile. - - Args: - document: Original document (package.json). - lockfile_path: Path to the existing lockfile. - - Returns: - Document with lockfile content if successful, None otherwise. - """ - lock_file_name = os.path.basename(lockfile_path) - logger.info( - 'Skipping npm install: using existing lockfile, %s', - {'path': document.path, 'lockfile': lock_file_name, 'lockfile_path': lockfile_path}, - ) - - relative_restore_file_path = build_dep_tree_path(document.path, lock_file_name) - restore_file_content = get_file_content(lockfile_path) - - if restore_file_content is not None: - logger.debug( - 'Successfully loaded lockfile content, %s', - {'path': document.path, 'lockfile': lock_file_name, 'content_size': len(restore_file_content)}, - ) - return Document(relative_restore_file_path, restore_file_content, self.is_git_diff) - - logger.warning( - 'Lockfile exists but could not read content, %s', - {'path': document.path, 'lockfile': lock_file_name, 'lockfile_path': lockfile_path}, - ) - return None - - def try_restore_dependencies(self, document: Document) -> Optional[Document]: - """Override to prevent npm install when any lockfile exists. - - The base class uses document.absolute_path which might be None or incorrect. - We need to use the same path resolution logic as get_manifest_file_path() - to ensure we check for lockfiles in the correct location. - - If any lockfile exists (package-lock.json, pnpm-lock.yaml, yarn.lock, deno.lock), - we use it directly without running npm install to avoid generating invalid lockfiles. - """ - # Check if this is a project file first (same as base class caller does) - if not self.is_project(document): - logger.debug('Skipping restore: document is not recognized as npm project, %s', {'path': document.path}) - return None - - # Resolve the manifest directory - manifest_dir = self._resolve_manifest_directory(document) - if not manifest_dir: - logger.debug( - 'Cannot determine manifest directory, proceeding with base class restore flow, %s', - {'path': document.path}, - ) - return super().try_restore_dependencies(document) - - # Check for existing lockfiles - logger.debug( - 'Checking for existing lockfiles in directory, %s', {'directory': manifest_dir, 'path': document.path} - ) - existing_lock_file, checked_lockfiles = self._find_existing_lockfile(manifest_dir) - - logger.debug( - 'Lockfile check results, %s', - {'path': document.path, 'checked_lockfiles': ', '.join(checked_lockfiles)}, - ) - - # If any lockfile exists, use it directly without running npm install - if existing_lock_file: - return self._create_document_from_lockfile(document, existing_lock_file) - - # No lockfile exists, proceed with the normal restore flow which will run npm install - logger.info( - 'No existing lockfile found, proceeding with npm install to generate package-lock.json, %s', - {'path': document.path, 'directory': manifest_dir, 'checked_lockfiles': ', '.join(checked_lockfiles)}, - ) - return super().try_restore_dependencies(document) + return True def get_commands(self, manifest_file_path: str) -> list[list[str]]: return [ @@ -159,22 +52,16 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: ] ] - def get_restored_lock_file_name(self, restore_file_path: str) -> str: - return os.path.basename(restore_file_path) - def get_lock_file_name(self) -> str: return NPM_LOCK_FILE_NAME def get_lock_file_names(self) -> list[str]: - return NPM_LOCK_FILE_NAMES + return [NPM_LOCK_FILE_NAME] @staticmethod def prepare_manifest_file_path_for_command(manifest_file_path: str) -> str: - # Remove package.json from the path if manifest_file_path.endswith(NPM_MANIFEST_FILE_NAME): - # Use os.path.dirname to handle both Unix (/) and Windows (\) separators - # This is cross-platform and handles edge cases correctly - dir_path = os.path.dirname(manifest_file_path) - # If dir_path is empty or just '.', return an empty string (package.json in current dir) + parent = Path(manifest_file_path).parent + dir_path = str(parent) return dir_path if dir_path and dir_path != '.' else '' return manifest_file_path diff --git a/cycode/cli/files_collector/sca/npm/restore_pnpm_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_pnpm_dependencies.py new file mode 100644 index 00000000..bce7eff6 --- /dev/null +++ b/cycode/cli/files_collector/sca/npm/restore_pnpm_dependencies.py @@ -0,0 +1,70 @@ +import json +from pathlib import Path +from typing import Optional + +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.logger import get_logger + +logger = get_logger('Pnpm Restore Dependencies') + +PNPM_MANIFEST_FILE_NAME = 'package.json' +PNPM_LOCK_FILE_NAME = 'pnpm-lock.yaml' + + +def _indicates_pnpm(package_json_content: Optional[str]) -> bool: + """Return True if package.json content signals that this project uses pnpm.""" + if not package_json_content: + return False + try: + data = json.loads(package_json_content) + except (json.JSONDecodeError, ValueError): + return False + + package_manager = data.get('packageManager', '') + if isinstance(package_manager, str) and package_manager.startswith('pnpm'): + return True + + engines = data.get('engines', {}) + return isinstance(engines, dict) and 'pnpm' in engines + + +class RestorePnpmDependencies(BaseRestoreDependencies): + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + if Path(document.path).name != PNPM_MANIFEST_FILE_NAME: + return False + + manifest_dir = self.get_manifest_dir(document) + if manifest_dir and (Path(manifest_dir) / PNPM_LOCK_FILE_NAME).is_file(): + return True + + return _indicates_pnpm(document.content) + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / PNPM_LOCK_FILE_NAME if manifest_dir else None + + if lockfile_path and lockfile_path.is_file(): + # Lockfile already exists — read it directly without running pnpm + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, PNPM_LOCK_FILE_NAME) + logger.debug('Using existing pnpm-lock.yaml, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + # Lockfile absent but pnpm is indicated in package.json — generate it + return super().try_restore_dependencies(document) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [['pnpm', 'install', '--ignore-scripts']] + + def get_lock_file_name(self) -> str: + return PNPM_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [PNPM_LOCK_FILE_NAME] diff --git a/cycode/cli/files_collector/sca/npm/restore_yarn_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_yarn_dependencies.py new file mode 100644 index 00000000..79b0c4ec --- /dev/null +++ b/cycode/cli/files_collector/sca/npm/restore_yarn_dependencies.py @@ -0,0 +1,70 @@ +import json +from pathlib import Path +from typing import Optional + +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.logger import get_logger + +logger = get_logger('Yarn Restore Dependencies') + +YARN_MANIFEST_FILE_NAME = 'package.json' +YARN_LOCK_FILE_NAME = 'yarn.lock' + + +def _indicates_yarn(package_json_content: Optional[str]) -> bool: + """Return True if package.json content signals that this project uses Yarn.""" + if not package_json_content: + return False + try: + data = json.loads(package_json_content) + except (json.JSONDecodeError, ValueError): + return False + + package_manager = data.get('packageManager', '') + if isinstance(package_manager, str) and package_manager.startswith('yarn'): + return True + + engines = data.get('engines', {}) + return isinstance(engines, dict) and 'yarn' in engines + + +class RestoreYarnDependencies(BaseRestoreDependencies): + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + if Path(document.path).name != YARN_MANIFEST_FILE_NAME: + return False + + manifest_dir = self.get_manifest_dir(document) + if manifest_dir and (Path(manifest_dir) / YARN_LOCK_FILE_NAME).is_file(): + return True + + return _indicates_yarn(document.content) + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / YARN_LOCK_FILE_NAME if manifest_dir else None + + if lockfile_path and lockfile_path.is_file(): + # Lockfile already exists — read it directly without running yarn + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, YARN_LOCK_FILE_NAME) + logger.debug('Using existing yarn.lock, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + # Lockfile absent but yarn is indicated in package.json — generate it + return super().try_restore_dependencies(document) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [['yarn', 'install', '--ignore-scripts']] + + def get_lock_file_name(self) -> str: + return YARN_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [YARN_LOCK_FILE_NAME] diff --git a/cycode/cli/files_collector/sca/php/__init__.py b/cycode/cli/files_collector/sca/php/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/files_collector/sca/php/restore_composer_dependencies.py b/cycode/cli/files_collector/sca/php/restore_composer_dependencies.py new file mode 100644 index 00000000..98b3564c --- /dev/null +++ b/cycode/cli/files_collector/sca/php/restore_composer_dependencies.py @@ -0,0 +1,54 @@ +from pathlib import Path +from typing import Optional + +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.logger import get_logger + +logger = get_logger('Composer Restore Dependencies') + +COMPOSER_MANIFEST_FILE_NAME = 'composer.json' +COMPOSER_LOCK_FILE_NAME = 'composer.lock' + + +class RestoreComposerDependencies(BaseRestoreDependencies): + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + return Path(document.path).name == COMPOSER_MANIFEST_FILE_NAME + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / COMPOSER_LOCK_FILE_NAME if manifest_dir else None + + if lockfile_path and lockfile_path.is_file(): + # Lockfile already exists — read it directly without running composer + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, COMPOSER_LOCK_FILE_NAME) + logger.debug('Using existing composer.lock, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + # Lockfile absent — generate it + return super().try_restore_dependencies(document) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [ + [ + 'composer', + 'update', + '--no-cache', + '--no-install', + '--no-scripts', + '--ignore-platform-reqs', + ] + ] + + def get_lock_file_name(self) -> str: + return COMPOSER_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [COMPOSER_LOCK_FILE_NAME] diff --git a/cycode/cli/files_collector/sca/python/__init__.py b/cycode/cli/files_collector/sca/python/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/files_collector/sca/python/restore_pipenv_dependencies.py b/cycode/cli/files_collector/sca/python/restore_pipenv_dependencies.py new file mode 100644 index 00000000..df91707c --- /dev/null +++ b/cycode/cli/files_collector/sca/python/restore_pipenv_dependencies.py @@ -0,0 +1,45 @@ +from pathlib import Path +from typing import Optional + +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.logger import get_logger + +logger = get_logger('Pipenv Restore Dependencies') + +PIPENV_MANIFEST_FILE_NAME = 'Pipfile' +PIPENV_LOCK_FILE_NAME = 'Pipfile.lock' + + +class RestorePipenvDependencies(BaseRestoreDependencies): + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + return Path(document.path).name == PIPENV_MANIFEST_FILE_NAME + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / PIPENV_LOCK_FILE_NAME if manifest_dir else None + + if lockfile_path and lockfile_path.is_file(): + # Lockfile already exists — read it directly without running pipenv + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, PIPENV_LOCK_FILE_NAME) + logger.debug('Using existing Pipfile.lock, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + # Lockfile absent — generate it + return super().try_restore_dependencies(document) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [['pipenv', 'lock']] + + def get_lock_file_name(self) -> str: + return PIPENV_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [PIPENV_LOCK_FILE_NAME] diff --git a/cycode/cli/files_collector/sca/python/restore_poetry_dependencies.py b/cycode/cli/files_collector/sca/python/restore_poetry_dependencies.py new file mode 100644 index 00000000..f681bd63 --- /dev/null +++ b/cycode/cli/files_collector/sca/python/restore_poetry_dependencies.py @@ -0,0 +1,62 @@ +from pathlib import Path +from typing import Optional + +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.logger import get_logger + +logger = get_logger('Poetry Restore Dependencies') + +POETRY_MANIFEST_FILE_NAME = 'pyproject.toml' +POETRY_LOCK_FILE_NAME = 'poetry.lock' + +# Section header that signals this pyproject.toml is managed by Poetry +_POETRY_TOOL_SECTION = '[tool.poetry]' + + +def _indicates_poetry(pyproject_content: Optional[str]) -> bool: + """Return True if pyproject.toml content signals that this project uses Poetry.""" + if not pyproject_content: + return False + return _POETRY_TOOL_SECTION in pyproject_content + + +class RestorePoetryDependencies(BaseRestoreDependencies): + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + if Path(document.path).name != POETRY_MANIFEST_FILE_NAME: + return False + + manifest_dir = self.get_manifest_dir(document) + if manifest_dir and (Path(manifest_dir) / POETRY_LOCK_FILE_NAME).is_file(): + return True + + return _indicates_poetry(document.content) + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / POETRY_LOCK_FILE_NAME if manifest_dir else None + + if lockfile_path and lockfile_path.is_file(): + # Lockfile already exists — read it directly without running poetry + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, POETRY_LOCK_FILE_NAME) + logger.debug('Using existing poetry.lock, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + # Lockfile absent but Poetry is indicated in pyproject.toml — generate it + return super().try_restore_dependencies(document) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [['poetry', 'lock']] + + def get_lock_file_name(self) -> str: + return POETRY_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [POETRY_LOCK_FILE_NAME] diff --git a/cycode/cli/files_collector/sca/sca_file_collector.py b/cycode/cli/files_collector/sca/sca_file_collector.py index 801b5d6f..b194deef 100644 --- a/cycode/cli/files_collector/sca/sca_file_collector.py +++ b/cycode/cli/files_collector/sca/sca_file_collector.py @@ -9,8 +9,14 @@ from cycode.cli.files_collector.sca.go.restore_go_dependencies import RestoreGoDependencies from cycode.cli.files_collector.sca.maven.restore_gradle_dependencies import RestoreGradleDependencies from cycode.cli.files_collector.sca.maven.restore_maven_dependencies import RestoreMavenDependencies +from cycode.cli.files_collector.sca.npm.restore_deno_dependencies import RestoreDenoDependencies from cycode.cli.files_collector.sca.npm.restore_npm_dependencies import RestoreNpmDependencies +from cycode.cli.files_collector.sca.npm.restore_pnpm_dependencies import RestorePnpmDependencies +from cycode.cli.files_collector.sca.npm.restore_yarn_dependencies import RestoreYarnDependencies from cycode.cli.files_collector.sca.nuget.restore_nuget_dependencies import RestoreNugetDependencies +from cycode.cli.files_collector.sca.php.restore_composer_dependencies import RestoreComposerDependencies +from cycode.cli.files_collector.sca.python.restore_pipenv_dependencies import RestorePipenvDependencies +from cycode.cli.files_collector.sca.python.restore_poetry_dependencies import RestorePoetryDependencies from cycode.cli.files_collector.sca.ruby.restore_ruby_dependencies import RestoreRubyDependencies from cycode.cli.files_collector.sca.sbt.restore_sbt_dependencies import RestoreSbtDependencies from cycode.cli.models import Document @@ -143,8 +149,14 @@ def _get_restore_handlers(ctx: typer.Context, is_git_diff: bool) -> list[BaseRes RestoreSbtDependencies(ctx, is_git_diff, build_dep_tree_timeout), RestoreGoDependencies(ctx, is_git_diff, build_dep_tree_timeout), RestoreNugetDependencies(ctx, is_git_diff, build_dep_tree_timeout), - RestoreNpmDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestoreYarnDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestorePnpmDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestoreDenoDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestoreNpmDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be after Yarn & Pnpm for fallback RestoreRubyDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestorePoetryDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestorePipenvDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestoreComposerDependencies(ctx, is_git_diff, build_dep_tree_timeout), ] diff --git a/tests/cli/files_collector/sca/npm/test_restore_deno_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_deno_dependencies.py new file mode 100644 index 00000000..2d6e9a4b --- /dev/null +++ b/tests/cli/files_collector/sca/npm/test_restore_deno_dependencies.py @@ -0,0 +1,65 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +import typer + +from cycode.cli.files_collector.sca.npm.restore_deno_dependencies import ( + DENO_LOCK_FILE_NAME, + DENO_MANIFEST_FILE_NAMES, + RestoreDenoDependencies, +) +from cycode.cli.models import Document + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_deno(mock_ctx: typer.Context) -> RestoreDenoDependencies: + return RestoreDenoDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + @pytest.mark.parametrize('filename', DENO_MANIFEST_FILE_NAMES) + def test_deno_manifest_files_match(self, restore_deno: RestoreDenoDependencies, filename: str) -> None: + doc = Document(filename, '{}') + assert restore_deno.is_project(doc) is True + + @pytest.mark.parametrize('filename', ['package.json', 'tsconfig.json', 'deno.ts', 'main.ts', 'deno.lock']) + def test_non_deno_manifest_files_do_not_match(self, restore_deno: RestoreDenoDependencies, filename: str) -> None: + doc = Document(filename, '') + assert restore_deno.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_deno_lock_returned(self, restore_deno: RestoreDenoDependencies, tmp_path: Path) -> None: + deno_lock_content = '{"version": "3", "packages": {}}' + (tmp_path / 'deno.json').write_text('{"imports": {}}') + (tmp_path / 'deno.lock').write_text(deno_lock_content) + + doc = Document(str(tmp_path / 'deno.json'), '{"imports": {}}', absolute_path=str(tmp_path / 'deno.json')) + result = restore_deno.try_restore_dependencies(doc) + + assert result is not None + assert DENO_LOCK_FILE_NAME in result.path + assert result.content == deno_lock_content + + def test_no_deno_lock_returns_none(self, restore_deno: RestoreDenoDependencies, tmp_path: Path) -> None: + (tmp_path / 'deno.json').write_text('{"imports": {}}') + + doc = Document(str(tmp_path / 'deno.json'), '{"imports": {}}', absolute_path=str(tmp_path / 'deno.json')) + result = restore_deno.try_restore_dependencies(doc) + + assert result is None + + def test_get_lock_file_name(self, restore_deno: RestoreDenoDependencies) -> None: + assert restore_deno.get_lock_file_name() == DENO_LOCK_FILE_NAME + + def test_get_commands_returns_empty(self, restore_deno: RestoreDenoDependencies) -> None: + assert restore_deno.get_commands('/path/to/deno.json') == [] diff --git a/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py index af990085..aa145de3 100644 --- a/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py +++ b/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py @@ -5,7 +5,6 @@ import typer from cycode.cli.files_collector.sca.npm.restore_npm_dependencies import ( - ALTERNATIVE_LOCK_FILES, NPM_LOCK_FILE_NAME, RestoreNpmDependencies, ) @@ -14,7 +13,6 @@ @pytest.fixture def mock_ctx(tmp_path: Path) -> typer.Context: - """Create a mock typer context.""" ctx = MagicMock(spec=typer.Context) ctx.obj = {'monitor': False} ctx.params = {'path': str(tmp_path)} @@ -22,326 +20,94 @@ def mock_ctx(tmp_path: Path) -> typer.Context: @pytest.fixture -def restore_npm_dependencies(mock_ctx: typer.Context) -> RestoreNpmDependencies: - """Create a RestoreNpmDependencies instance.""" +def restore_npm(mock_ctx: typer.Context) -> RestoreNpmDependencies: return RestoreNpmDependencies(mock_ctx, is_git_diff=False, command_timeout=30) -class TestRestoreNpmDependenciesAlternativeLockfiles: - """Test that lockfiles prevent npm install from running.""" +class TestIsProject: + def test_package_json_with_no_lockfile_matches(self, restore_npm: RestoreNpmDependencies, tmp_path: Path) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_npm.is_project(doc) is True - @pytest.mark.parametrize( - ('lockfile_name', 'lockfile_content', 'expected_content'), - [ - ('pnpm-lock.yaml', 'lockfileVersion: 5.4\n', 'lockfileVersion: 5.4\n'), - ('yarn.lock', '# yarn lockfile v1\n', '# yarn lockfile v1\n'), - ('deno.lock', '{"version": 2}\n', '{"version": 2}\n'), - ('package-lock.json', '{"lockfileVersion": 2}\n', '{"lockfileVersion": 2}\n'), - ], - ) - def test_lockfile_exists_should_skip_npm_install( - self, - restore_npm_dependencies: RestoreNpmDependencies, - tmp_path: Path, - lockfile_name: str, - lockfile_content: str, - expected_content: str, + def test_package_json_with_yarn_lock_does_not_match( + self, restore_npm: RestoreNpmDependencies, tmp_path: Path ) -> None: - """Test that when any lockfile exists, npm install is skipped.""" - # Setup: Create package.json and lockfile - package_json_path = tmp_path / 'package.json' - lockfile_path = tmp_path / lockfile_name - - package_json_path.write_text('{"name": "test", "version": "1.0.0"}') - lockfile_path.write_text(lockfile_content) + """Yarn projects are handled by RestoreYarnDependencies — NPM should not claim them.""" + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'yarn.lock').write_text('# yarn lockfile v1\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_npm.is_project(doc) is False + + def test_package_json_with_pnpm_lock_does_not_match( + self, restore_npm: RestoreNpmDependencies, tmp_path: Path + ) -> None: + """pnpm projects are handled by RestorePnpmDependencies — NPM should not claim them.""" + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'pnpm-lock.yaml').write_text('lockfileVersion: 5.4\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_npm.is_project(doc) is False - document = Document( - path=str(package_json_path), - content=package_json_path.read_text(), - absolute_path=str(package_json_path), - ) + def test_tsconfig_json_does_not_match(self, restore_npm: RestoreNpmDependencies) -> None: + doc = Document('tsconfig.json', '{}') + assert restore_npm.is_project(doc) is False - # Execute - result = restore_npm_dependencies.try_restore_dependencies(document) + def test_arbitrary_json_does_not_match(self, restore_npm: RestoreNpmDependencies) -> None: + for filename in ('jest.config.json', '.eslintrc.json', 'settings.json', 'bom.json'): + doc = Document(filename, '{}') + assert restore_npm.is_project(doc) is False, f'Expected False for {filename}' - # Verify: Should return lockfile content without running npm install - assert result is not None - assert lockfile_name in result.path - assert result.content == expected_content + def test_non_json_file_does_not_match(self, restore_npm: RestoreNpmDependencies) -> None: + for filename in ('readme.txt', 'script.js', 'Makefile'): + doc = Document(filename, '') + assert restore_npm.is_project(doc) is False, f'Expected False for {filename}' - def test_no_lockfile_exists_should_proceed_with_normal_flow( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path - ) -> None: - """Test that when no lockfile exists, normal flow proceeds (will run npm install).""" - # Setup: Create only package.json (no lockfile) - package_json_path = tmp_path / 'package.json' - package_json_path.write_text('{"name": "test", "version": "1.0.0"}') - document = Document( - path=str(package_json_path), - content=package_json_path.read_text(), - absolute_path=str(package_json_path), - ) +class TestTryRestoreDependencies: + def test_no_lockfile_calls_base_class(self, restore_npm: RestoreNpmDependencies, tmp_path: Path) -> None: + """When no lockfile exists, the base class (npm install) should be invoked.""" + (tmp_path / 'package.json').write_text('{"name": "test"}') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) - # Mock the base class's try_restore_dependencies to verify it's called with patch.object( - restore_npm_dependencies.__class__.__bases__[0], - 'try_restore_dependencies', - return_value=None, + restore_npm.__class__.__bases__[0], 'try_restore_dependencies', return_value=None ) as mock_super: - # Execute - restore_npm_dependencies.try_restore_dependencies(document) - - # Verify: Should call parent's try_restore_dependencies (which will run npm install) - mock_super.assert_called_once_with(document) + restore_npm.try_restore_dependencies(doc) + mock_super.assert_called_once_with(doc) - -class TestRestoreNpmDependenciesPathResolution: - """Test path resolution scenarios.""" - - @pytest.mark.parametrize( - 'has_absolute_path', - [True, False], - ) - def test_path_resolution_with_different_path_types( - self, - restore_npm_dependencies: RestoreNpmDependencies, - tmp_path: Path, - has_absolute_path: bool, + def test_lockfile_in_different_directory_still_calls_base_class( + self, restore_npm: RestoreNpmDependencies, tmp_path: Path ) -> None: - """Test path resolution with absolute or relative paths.""" - package_json_path = tmp_path / 'package.json' - pnpm_lock_path = tmp_path / 'pnpm-lock.yaml' - - package_json_path.write_text('{"name": "test"}') - pnpm_lock_path.write_text('lockfileVersion: 5.4\n') - - document = Document( - path=str(package_json_path), - content='{"name": "test"}', - absolute_path=str(package_json_path) if has_absolute_path else None, - ) - - result = restore_npm_dependencies.try_restore_dependencies(document) - - assert result is not None - assert result.content == 'lockfileVersion: 5.4\n' - - def test_path_resolution_in_monitor_mode(self, tmp_path: Path) -> None: - """Test path resolution in monitor mode.""" - # Setup monitor mode context - ctx = MagicMock(spec=typer.Context) - ctx.obj = {'monitor': True} - ctx.params = {'path': str(tmp_path)} - - restore_npm = RestoreNpmDependencies(ctx, is_git_diff=False, command_timeout=30) - - # Create files in a subdirectory - subdir = tmp_path / 'project' - subdir.mkdir() - package_json_path = subdir / 'package.json' - pnpm_lock_path = subdir / 'pnpm-lock.yaml' - - package_json_path.write_text('{"name": "test"}') - pnpm_lock_path.write_text('lockfileVersion: 5.4\n') - - # Document with a relative path - document = Document( - path='project/package.json', - content='{"name": "test"}', - absolute_path=str(package_json_path), - ) - - result = restore_npm.try_restore_dependencies(document) - - assert result is not None - assert result.content == 'lockfileVersion: 5.4\n' - - def test_path_resolution_with_nested_directory( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path - ) -> None: - """Test path resolution with a nested directory structure.""" - subdir = tmp_path / 'src' / 'app' - subdir.mkdir(parents=True) - - package_json_path = subdir / 'package.json' - pnpm_lock_path = subdir / 'pnpm-lock.yaml' - - package_json_path.write_text('{"name": "test"}') - pnpm_lock_path.write_text('lockfileVersion: 5.4\n') - - document = Document( - path=str(package_json_path), - content='{"name": "test"}', - absolute_path=str(package_json_path), - ) - - result = restore_npm_dependencies.try_restore_dependencies(document) - - assert result is not None - assert result.content == 'lockfileVersion: 5.4\n' - - -class TestRestoreNpmDependenciesEdgeCases: - """Test edge cases and error scenarios.""" - - def test_empty_lockfile_should_still_be_used( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path - ) -> None: - """Test that the empty lockfile is still used (prevents npm install).""" - package_json_path = tmp_path / 'package.json' - pnpm_lock_path = tmp_path / 'pnpm-lock.yaml' - - package_json_path.write_text('{"name": "test"}') - pnpm_lock_path.write_text('') # Empty file - - document = Document( - path=str(package_json_path), - content='{"name": "test"}', - absolute_path=str(package_json_path), - ) - - result = restore_npm_dependencies.try_restore_dependencies(document) - - # Should still return the empty lockfile (prevents npm install) - assert result is not None - assert result.content == '' - - def test_multiple_lockfiles_should_use_first_found( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path - ) -> None: - """Test that when multiple lockfiles exist, the first one found is used (package-lock.json has priority).""" - package_json_path = tmp_path / 'package.json' - package_lock_path = tmp_path / 'package-lock.json' - yarn_lock_path = tmp_path / 'yarn.lock' - pnpm_lock_path = tmp_path / 'pnpm-lock.yaml' - - package_json_path.write_text('{"name": "test"}') - package_lock_path.write_text('{"lockfileVersion": 2}\n') - yarn_lock_path.write_text('# yarn lockfile\n') - pnpm_lock_path.write_text('lockfileVersion: 5.4\n') - - document = Document( - path=str(package_json_path), - content='{"name": "test"}', - absolute_path=str(package_json_path), - ) - - result = restore_npm_dependencies.try_restore_dependencies(document) - - # Should use package-lock.json (first in the check order) - assert result is not None - assert 'package-lock.json' in result.path - assert result.content == '{"lockfileVersion": 2}\n' - - def test_multiple_alternative_lockfiles_should_use_first_found( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path - ) -> None: - """Test that when multiple alternative lockfiles exist (but no package-lock.json), - the first one found is used.""" - package_json_path = tmp_path / 'package.json' - yarn_lock_path = tmp_path / 'yarn.lock' - pnpm_lock_path = tmp_path / 'pnpm-lock.yaml' - - package_json_path.write_text('{"name": "test"}') - yarn_lock_path.write_text('# yarn lockfile\n') - pnpm_lock_path.write_text('lockfileVersion: 5.4\n') - - document = Document( - path=str(package_json_path), - content='{"name": "test"}', - absolute_path=str(package_json_path), - ) - - result = restore_npm_dependencies.try_restore_dependencies(document) - - # Should use yarn.lock (first in ALTERNATIVE_LOCK_FILES list) - assert result is not None - assert 'yarn.lock' in result.path - assert result.content == '# yarn lockfile\n' - - def test_lockfile_in_different_directory_should_not_be_found( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path - ) -> None: - """Test that lockfile in a different directory is not found.""" - package_json_path = tmp_path / 'package.json' + (tmp_path / 'package.json').write_text('{"name": "test"}') other_dir = tmp_path / 'other' other_dir.mkdir() - pnpm_lock_path = other_dir / 'pnpm-lock.yaml' - - package_json_path.write_text('{"name": "test"}') - pnpm_lock_path.write_text('lockfileVersion: 5.4\n') + (other_dir / 'pnpm-lock.yaml').write_text('lockfileVersion: 5.4\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) - document = Document( - path=str(package_json_path), - content='{"name": "test"}', - absolute_path=str(package_json_path), - ) - - # Mock the base class to verify it's called (since lockfile not found) with patch.object( - restore_npm_dependencies.__class__.__bases__[0], - 'try_restore_dependencies', - return_value=None, + restore_npm.__class__.__bases__[0], 'try_restore_dependencies', return_value=None ) as mock_super: - restore_npm_dependencies.try_restore_dependencies(document) - - # Should proceed with normal flow since lockfile not in same directory - mock_super.assert_called_once_with(document) - - def test_non_json_file_should_not_trigger_restore( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path - ) -> None: - """Test that non-JSON files don't trigger restore.""" - text_file = tmp_path / 'readme.txt' - text_file.write_text('Some text') - - document = Document( - path=str(text_file), - content='Some text', - absolute_path=str(text_file), - ) - - # Should return None because is_project() returns False - result = restore_npm_dependencies.try_restore_dependencies(document) - - assert result is None - - -class TestRestoreNpmDependenciesHelperMethods: - """Test helper methods.""" - - def test_is_project_with_json_file(self, restore_npm_dependencies: RestoreNpmDependencies) -> None: - """Test is_project identifies JSON files correctly.""" - document = Document('package.json', '{}') - assert restore_npm_dependencies.is_project(document) is True + restore_npm.try_restore_dependencies(doc) + mock_super.assert_called_once_with(doc) - document = Document('tsconfig.json', '{}') - assert restore_npm_dependencies.is_project(document) is True - def test_is_project_with_non_json_file(self, restore_npm_dependencies: RestoreNpmDependencies) -> None: - """Test is_project returns False for non-JSON files.""" - document = Document('readme.txt', 'text') - assert restore_npm_dependencies.is_project(document) is False +class TestGetLockFileName: + def test_get_lock_file_name(self, restore_npm: RestoreNpmDependencies) -> None: + assert restore_npm.get_lock_file_name() == NPM_LOCK_FILE_NAME - document = Document('script.js', 'code') - assert restore_npm_dependencies.is_project(document) is False + def test_get_lock_file_names_contains_only_npm_lock(self, restore_npm: RestoreNpmDependencies) -> None: + assert restore_npm.get_lock_file_names() == [NPM_LOCK_FILE_NAME] - def test_get_lock_file_name(self, restore_npm_dependencies: RestoreNpmDependencies) -> None: - """Test get_lock_file_name returns the correct name.""" - assert restore_npm_dependencies.get_lock_file_name() == NPM_LOCK_FILE_NAME - def test_get_lock_file_names(self, restore_npm_dependencies: RestoreNpmDependencies) -> None: - """Test get_lock_file_names returns all lockfile names.""" - lock_file_names = restore_npm_dependencies.get_lock_file_names() - assert NPM_LOCK_FILE_NAME in lock_file_names - for alt_lock in ALTERNATIVE_LOCK_FILES: - assert alt_lock in lock_file_names +class TestPrepareManifestFilePath: + def test_strips_package_json_filename(self, restore_npm: RestoreNpmDependencies) -> None: + path = str(Path('/path/to/package.json')) + expected = str(Path('/path/to')) + assert restore_npm.prepare_manifest_file_path_for_command(path) == expected - def test_prepare_manifest_file_path_for_command(self, restore_npm_dependencies: RestoreNpmDependencies) -> None: - """Test prepare_manifest_file_path_for_command removes package.json from the path.""" - result = restore_npm_dependencies.prepare_manifest_file_path_for_command('/path/to/package.json') - assert result == '/path/to' + def test_package_json_in_cwd_returns_empty_string(self, restore_npm: RestoreNpmDependencies) -> None: + assert restore_npm.prepare_manifest_file_path_for_command('package.json') == '' - result = restore_npm_dependencies.prepare_manifest_file_path_for_command('package.json') - assert result == '' + def test_non_package_json_path_returned_unchanged(self, restore_npm: RestoreNpmDependencies) -> None: + path = str(Path('/path/to/')) + assert restore_npm.prepare_manifest_file_path_for_command(path) == path diff --git a/tests/cli/files_collector/sca/npm/test_restore_pnpm_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_pnpm_dependencies.py new file mode 100644 index 00000000..312cce83 --- /dev/null +++ b/tests/cli/files_collector/sca/npm/test_restore_pnpm_dependencies.py @@ -0,0 +1,91 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +import typer + +from cycode.cli.files_collector.sca.npm.restore_pnpm_dependencies import ( + PNPM_LOCK_FILE_NAME, + RestorePnpmDependencies, +) +from cycode.cli.models import Document + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_pnpm(mock_ctx: typer.Context) -> RestorePnpmDependencies: + return RestorePnpmDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_package_json_with_pnpm_lock_matches(self, restore_pnpm: RestorePnpmDependencies, tmp_path: Path) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'pnpm-lock.yaml').write_text('lockfileVersion: 5.4\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_pnpm.is_project(doc) is True + + def test_package_json_with_package_manager_pnpm_matches(self, restore_pnpm: RestorePnpmDependencies) -> None: + content = '{"name": "test", "packageManager": "pnpm@8.6.2"}' + doc = Document('package.json', content) + assert restore_pnpm.is_project(doc) is True + + def test_package_json_with_engines_pnpm_matches(self, restore_pnpm: RestorePnpmDependencies) -> None: + content = '{"name": "test", "engines": {"pnpm": ">=8"}}' + doc = Document('package.json', content) + assert restore_pnpm.is_project(doc) is True + + def test_package_json_with_no_pnpm_signal_does_not_match( + self, restore_pnpm: RestorePnpmDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_pnpm.is_project(doc) is False + + def test_package_json_with_yarn_lock_does_not_match( + self, restore_pnpm: RestorePnpmDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'yarn.lock').write_text('# yarn lockfile v1\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_pnpm.is_project(doc) is False + + def test_tsconfig_json_does_not_match(self, restore_pnpm: RestorePnpmDependencies) -> None: + doc = Document('tsconfig.json', '{"compilerOptions": {}}') + assert restore_pnpm.is_project(doc) is False + + def test_package_manager_yarn_does_not_match(self, restore_pnpm: RestorePnpmDependencies) -> None: + content = '{"name": "test", "packageManager": "yarn@4.0.0"}' + doc = Document('package.json', content) + assert restore_pnpm.is_project(doc) is False + + def test_invalid_json_content_does_not_match(self, restore_pnpm: RestorePnpmDependencies) -> None: + doc = Document('package.json', 'not valid json') + assert restore_pnpm.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_pnpm_lock_returned_directly(self, restore_pnpm: RestorePnpmDependencies, tmp_path: Path) -> None: + pnpm_lock_content = 'lockfileVersion: 5.4\n\npackages:\n /package@1.0.0:\n resolution: {}\n' + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'pnpm-lock.yaml').write_text(pnpm_lock_content) + + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + result = restore_pnpm.try_restore_dependencies(doc) + + assert result is not None + assert PNPM_LOCK_FILE_NAME in result.path + assert result.content == pnpm_lock_content + + def test_get_lock_file_name(self, restore_pnpm: RestorePnpmDependencies) -> None: + assert restore_pnpm.get_lock_file_name() == PNPM_LOCK_FILE_NAME + + def test_get_commands_returns_pnpm_install(self, restore_pnpm: RestorePnpmDependencies) -> None: + commands = restore_pnpm.get_commands('/path/to/package.json') + assert commands == [['pnpm', 'install', '--ignore-scripts']] diff --git a/tests/cli/files_collector/sca/npm/test_restore_yarn_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_yarn_dependencies.py new file mode 100644 index 00000000..13e321c9 --- /dev/null +++ b/tests/cli/files_collector/sca/npm/test_restore_yarn_dependencies.py @@ -0,0 +1,91 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +import typer + +from cycode.cli.files_collector.sca.npm.restore_yarn_dependencies import ( + YARN_LOCK_FILE_NAME, + RestoreYarnDependencies, +) +from cycode.cli.models import Document + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_yarn(mock_ctx: typer.Context) -> RestoreYarnDependencies: + return RestoreYarnDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_package_json_with_yarn_lock_matches(self, restore_yarn: RestoreYarnDependencies, tmp_path: Path) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'yarn.lock').write_text('# yarn lockfile v1\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_yarn.is_project(doc) is True + + def test_package_json_with_package_manager_yarn_matches(self, restore_yarn: RestoreYarnDependencies) -> None: + content = '{"name": "test", "packageManager": "yarn@4.0.2"}' + doc = Document('package.json', content) + assert restore_yarn.is_project(doc) is True + + def test_package_json_with_engines_yarn_matches(self, restore_yarn: RestoreYarnDependencies) -> None: + content = '{"name": "test", "engines": {"yarn": ">=1.22"}}' + doc = Document('package.json', content) + assert restore_yarn.is_project(doc) is True + + def test_package_json_with_no_yarn_signal_does_not_match( + self, restore_yarn: RestoreYarnDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_yarn.is_project(doc) is False + + def test_package_json_with_pnpm_lock_does_not_match( + self, restore_yarn: RestoreYarnDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'pnpm-lock.yaml').write_text('lockfileVersion: 5.4\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_yarn.is_project(doc) is False + + def test_tsconfig_json_does_not_match(self, restore_yarn: RestoreYarnDependencies) -> None: + doc = Document('tsconfig.json', '{"compilerOptions": {}}') + assert restore_yarn.is_project(doc) is False + + def test_package_manager_npm_does_not_match(self, restore_yarn: RestoreYarnDependencies) -> None: + content = '{"name": "test", "packageManager": "npm@9.0.0"}' + doc = Document('package.json', content) + assert restore_yarn.is_project(doc) is False + + def test_invalid_json_content_does_not_match(self, restore_yarn: RestoreYarnDependencies) -> None: + doc = Document('package.json', 'not valid json') + assert restore_yarn.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_yarn_lock_returned_directly(self, restore_yarn: RestoreYarnDependencies, tmp_path: Path) -> None: + yarn_lock_content = '# yarn lockfile v1\n\npackage@1.0.0:\n resolved "https://example.com"\n' + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'yarn.lock').write_text(yarn_lock_content) + + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + result = restore_yarn.try_restore_dependencies(doc) + + assert result is not None + assert YARN_LOCK_FILE_NAME in result.path + assert result.content == yarn_lock_content + + def test_get_lock_file_name(self, restore_yarn: RestoreYarnDependencies) -> None: + assert restore_yarn.get_lock_file_name() == YARN_LOCK_FILE_NAME + + def test_get_commands_returns_yarn_install(self, restore_yarn: RestoreYarnDependencies) -> None: + commands = restore_yarn.get_commands('/path/to/package.json') + assert commands == [['yarn', 'install', '--ignore-scripts']] diff --git a/tests/cli/files_collector/sca/php/__init__.py b/tests/cli/files_collector/sca/php/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/php/test_restore_composer_dependencies.py b/tests/cli/files_collector/sca/php/test_restore_composer_dependencies.py new file mode 100644 index 00000000..463eeddb --- /dev/null +++ b/tests/cli/files_collector/sca/php/test_restore_composer_dependencies.py @@ -0,0 +1,82 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +import typer + +from cycode.cli.files_collector.sca.php.restore_composer_dependencies import ( + COMPOSER_LOCK_FILE_NAME, + RestoreComposerDependencies, +) +from cycode.cli.models import Document + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_composer(mock_ctx: typer.Context) -> RestoreComposerDependencies: + return RestoreComposerDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_composer_json_matches(self, restore_composer: RestoreComposerDependencies) -> None: + doc = Document('composer.json', '{"name": "vendor/project"}\n') + assert restore_composer.is_project(doc) is True + + def test_composer_json_in_subdir_matches(self, restore_composer: RestoreComposerDependencies) -> None: + doc = Document('myapp/composer.json', '{"name": "vendor/project"}\n') + assert restore_composer.is_project(doc) is True + + def test_composer_lock_does_not_match(self, restore_composer: RestoreComposerDependencies) -> None: + doc = Document('composer.lock', '{"_readme": []}\n') + assert restore_composer.is_project(doc) is False + + def test_package_json_does_not_match(self, restore_composer: RestoreComposerDependencies) -> None: + doc = Document('package.json', '{"name": "test"}\n') + assert restore_composer.is_project(doc) is False + + def test_other_json_does_not_match(self, restore_composer: RestoreComposerDependencies) -> None: + doc = Document('config.json', '{"setting": "value"}\n') + assert restore_composer.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_composer_lock_returned_directly( + self, restore_composer: RestoreComposerDependencies, tmp_path: Path + ) -> None: + lock_content = '{\n "_readme": ["This file is @generated by Composer"],\n "packages": []\n}\n' + (tmp_path / 'composer.json').write_text('{"name": "vendor/project"}\n') + (tmp_path / 'composer.lock').write_text(lock_content) + + doc = Document( + str(tmp_path / 'composer.json'), + '{"name": "vendor/project"}\n', + absolute_path=str(tmp_path / 'composer.json'), + ) + result = restore_composer.try_restore_dependencies(doc) + + assert result is not None + assert COMPOSER_LOCK_FILE_NAME in result.path + assert result.content == lock_content + + def test_get_lock_file_name(self, restore_composer: RestoreComposerDependencies) -> None: + assert restore_composer.get_lock_file_name() == COMPOSER_LOCK_FILE_NAME + + def test_get_commands_returns_composer_update(self, restore_composer: RestoreComposerDependencies) -> None: + commands = restore_composer.get_commands('/path/to/composer.json') + assert commands == [ + [ + 'composer', + 'update', + '--no-cache', + '--no-install', + '--no-scripts', + '--ignore-platform-reqs', + ] + ] diff --git a/tests/cli/files_collector/sca/python/__init__.py b/tests/cli/files_collector/sca/python/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/python/test_restore_pipenv_dependencies.py b/tests/cli/files_collector/sca/python/test_restore_pipenv_dependencies.py new file mode 100644 index 00000000..9d34a7e3 --- /dev/null +++ b/tests/cli/files_collector/sca/python/test_restore_pipenv_dependencies.py @@ -0,0 +1,73 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +import typer + +from cycode.cli.files_collector.sca.python.restore_pipenv_dependencies import ( + PIPENV_LOCK_FILE_NAME, + RestorePipenvDependencies, +) +from cycode.cli.models import Document + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_pipenv(mock_ctx: typer.Context) -> RestorePipenvDependencies: + return RestorePipenvDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_pipfile_matches(self, restore_pipenv: RestorePipenvDependencies) -> None: + doc = Document('Pipfile', '[[source]]\nname = "pypi"\n') + assert restore_pipenv.is_project(doc) is True + + def test_pipfile_in_subdir_matches(self, restore_pipenv: RestorePipenvDependencies) -> None: + doc = Document('myapp/Pipfile', '[[source]]\nname = "pypi"\n') + assert restore_pipenv.is_project(doc) is True + + def test_pipfile_lock_does_not_match(self, restore_pipenv: RestorePipenvDependencies) -> None: + doc = Document('Pipfile.lock', '{"default": {}}\n') + assert restore_pipenv.is_project(doc) is False + + def test_requirements_txt_does_not_match(self, restore_pipenv: RestorePipenvDependencies) -> None: + doc = Document('requirements.txt', 'requests==2.31.0\n') + assert restore_pipenv.is_project(doc) is False + + def test_pyproject_toml_does_not_match(self, restore_pipenv: RestorePipenvDependencies) -> None: + doc = Document('pyproject.toml', '[build-system]\nrequires = ["setuptools"]\n') + assert restore_pipenv.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_pipfile_lock_returned_directly( + self, restore_pipenv: RestorePipenvDependencies, tmp_path: Path + ) -> None: + lock_content = '{"_meta": {"hash": {"sha256": "abc"}}, "default": {}, "develop": {}}\n' + (tmp_path / 'Pipfile').write_text('[[source]]\nname = "pypi"\n') + (tmp_path / 'Pipfile.lock').write_text(lock_content) + + doc = Document( + str(tmp_path / 'Pipfile'), + '[[source]]\nname = "pypi"\n', + absolute_path=str(tmp_path / 'Pipfile'), + ) + result = restore_pipenv.try_restore_dependencies(doc) + + assert result is not None + assert PIPENV_LOCK_FILE_NAME in result.path + assert result.content == lock_content + + def test_get_lock_file_name(self, restore_pipenv: RestorePipenvDependencies) -> None: + assert restore_pipenv.get_lock_file_name() == PIPENV_LOCK_FILE_NAME + + def test_get_commands_returns_pipenv_lock(self, restore_pipenv: RestorePipenvDependencies) -> None: + commands = restore_pipenv.get_commands('/path/to/Pipfile') + assert commands == [['pipenv', 'lock']] diff --git a/tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py b/tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py new file mode 100644 index 00000000..73f0d14f --- /dev/null +++ b/tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py @@ -0,0 +1,99 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +import typer + +from cycode.cli.files_collector.sca.python.restore_poetry_dependencies import ( + POETRY_LOCK_FILE_NAME, + RestorePoetryDependencies, +) +from cycode.cli.models import Document + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_poetry(mock_ctx: typer.Context) -> RestorePoetryDependencies: + return RestorePoetryDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_pyproject_toml_with_poetry_lock_matches( + self, restore_poetry: RestorePoetryDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'pyproject.toml').write_text('[tool.poetry]\nname = "test"\n') + (tmp_path / 'poetry.lock').write_text('# This file is generated by Poetry\n') + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[tool.poetry]\nname = "test"\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + assert restore_poetry.is_project(doc) is True + + def test_pyproject_toml_with_tool_poetry_section_matches(self, restore_poetry: RestorePoetryDependencies) -> None: + content = '[tool.poetry]\nname = "my-project"\nversion = "1.0.0"\n' + doc = Document('pyproject.toml', content) + assert restore_poetry.is_project(doc) is True + + def test_pyproject_toml_without_poetry_section_does_not_match( + self, restore_poetry: RestorePoetryDependencies, tmp_path: Path + ) -> None: + content = '[build-system]\nrequires = ["setuptools"]\n' + (tmp_path / 'pyproject.toml').write_text(content) + doc = Document( + str(tmp_path / 'pyproject.toml'), + content, + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + assert restore_poetry.is_project(doc) is False + + def test_requirements_txt_does_not_match(self, restore_poetry: RestorePoetryDependencies) -> None: + doc = Document('requirements.txt', 'requests==2.31.0\n') + assert restore_poetry.is_project(doc) is False + + def test_setup_py_does_not_match(self, restore_poetry: RestorePoetryDependencies) -> None: + doc = Document('setup.py', 'from setuptools import setup\nsetup()\n') + assert restore_poetry.is_project(doc) is False + + def test_empty_content_does_not_match(self, restore_poetry: RestorePoetryDependencies, tmp_path: Path) -> None: + (tmp_path / 'pyproject.toml').write_text('') + doc = Document( + str(tmp_path / 'pyproject.toml'), + '', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + assert restore_poetry.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_poetry_lock_returned_directly( + self, restore_poetry: RestorePoetryDependencies, tmp_path: Path + ) -> None: + lock_content = '# This file is generated by Poetry\n\n[[package]]\nname = "requests"\n' + (tmp_path / 'pyproject.toml').write_text('[tool.poetry]\nname = "test"\n') + (tmp_path / 'poetry.lock').write_text(lock_content) + + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[tool.poetry]\nname = "test"\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + result = restore_poetry.try_restore_dependencies(doc) + + assert result is not None + assert POETRY_LOCK_FILE_NAME in result.path + assert result.content == lock_content + + def test_get_lock_file_name(self, restore_poetry: RestorePoetryDependencies) -> None: + assert restore_poetry.get_lock_file_name() == POETRY_LOCK_FILE_NAME + + def test_get_commands_returns_poetry_lock(self, restore_poetry: RestorePoetryDependencies) -> None: + commands = restore_poetry.get_commands('/path/to/pyproject.toml') + assert commands == [['poetry', 'lock']] From d9ce12c3af14610b6896bb27e8be5ace5839b214 Mon Sep 17 00:00:00 2001 From: Mateusz Sterczewski Date: Tue, 3 Mar 2026 15:40:19 +0100 Subject: [PATCH 244/257] CM-60184-Scans using presigned post url (#395) --- cycode/cli/apps/scan/code_scanner.py | 80 +++++++++++++++++++- cycode/cli/apps/scan/commit_range_scanner.py | 59 +++++++++++++-- cycode/cli/consts.py | 7 +- cycode/cli/files_collector/zip_documents.py | 6 +- cycode/cli/utils/scan_batch.py | 6 +- cycode/cli/utils/scan_utils.py | 5 ++ cycode/cyclient/models.py | 20 +++++ cycode/cyclient/scan_client.py | 61 +++++++++++++++ 8 files changed, 228 insertions(+), 16 deletions(-) diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index 3ffefd0f..5e5d0555 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -29,12 +29,15 @@ generate_unique_scan_id, is_cycodeignore_allowed_by_scan_config, set_issue_detected_by_scan_results, + should_use_presigned_upload, ) from cycode.cyclient.models import ZippedFileScanResult from cycode.logger import get_logger if TYPE_CHECKING: from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip + from cycode.cli.printers.console_printer import ConsolePrinter + from cycode.cli.utils.progress_bar import BaseProgressBar from cycode.cyclient.scan_client import ScanClient start_scan_time = time.time() @@ -106,7 +109,10 @@ def _should_use_sync_flow(command_scan_type: str, scan_type: str, sync_option: b def _get_scan_documents_thread_func( - ctx: typer.Context, is_git_diff: bool, is_commit_range: bool, scan_parameters: dict + ctx: typer.Context, + is_git_diff: bool, + is_commit_range: bool, + scan_parameters: dict, ) -> Callable[[list[Document]], tuple[str, CliError, LocalScanResult]]: cycode_client = ctx.obj['client'] scan_type = ctx.obj['scan_type'] @@ -180,6 +186,36 @@ def _scan_batch_thread_func(batch: list[Document]) -> tuple[str, CliError, Local return _scan_batch_thread_func +def _run_presigned_upload_scan( + scan_batch_thread_func: Callable, + scan_type: str, + documents_to_scan: list[Document], + progress_bar: 'BaseProgressBar', + printer: 'ConsolePrinter', +) -> tuple: + try: + # Try to zip all documents as a single batch; ZipTooLargeError raised if it exceeds the scan type's limit + zip_documents(scan_type, documents_to_scan) + # It fits: skip batching and upload everything as one ZIP + return run_parallel_batched_scan( + scan_batch_thread_func, + scan_type, + documents_to_scan, + progress_bar=progress_bar, + skip_batching=True, + ) + except custom_exceptions.ZipTooLargeError: + printer.print_warning( + 'The scan is too large to upload as a single file. This may result in corrupted scan results.' + ) + return run_parallel_batched_scan( + scan_batch_thread_func, + scan_type, + documents_to_scan, + progress_bar=progress_bar, + ) + + def scan_documents( ctx: typer.Context, documents_to_scan: list[Document], @@ -203,9 +239,15 @@ def scan_documents( return scan_batch_thread_func = _get_scan_documents_thread_func(ctx, is_git_diff, is_commit_range, scan_parameters) - errors, local_scan_results = run_parallel_batched_scan( - scan_batch_thread_func, scan_type, documents_to_scan, progress_bar=progress_bar - ) + + if should_use_presigned_upload(scan_type): + errors, local_scan_results = _run_presigned_upload_scan( + scan_batch_thread_func, scan_type, documents_to_scan, progress_bar, printer + ) + else: + errors, local_scan_results = run_parallel_batched_scan( + scan_batch_thread_func, scan_type, documents_to_scan, progress_bar=progress_bar + ) try_set_aggregation_report_url_if_needed(ctx, scan_parameters, ctx.obj['client'], scan_type) @@ -217,6 +259,31 @@ def scan_documents( print_local_scan_results(ctx, local_scan_results, errors) +def _perform_scan_v4_async( + cycode_client: 'ScanClient', + zipped_documents: 'InMemoryZip', + scan_type: str, + scan_parameters: dict, + is_git_diff: bool, + is_commit_range: bool, +) -> ZippedFileScanResult: + upload_link = cycode_client.get_upload_link(scan_type) + logger.debug('Got upload link, %s', {'upload_id': upload_link.upload_id}) + + cycode_client.upload_to_presigned_post(upload_link.url, upload_link.presigned_post_fields, zipped_documents) + logger.debug('Uploaded zip to presigned URL') + + scan_async_result = cycode_client.scan_repository_from_upload_id( + scan_type, upload_link.upload_id, scan_parameters, is_git_diff, is_commit_range + ) + logger.debug( + 'Presigned upload scan request triggered, %s', + {'scan_id': scan_async_result.scan_id, 'upload_id': upload_link.upload_id}, + ) + + return poll_scan_results(cycode_client, scan_async_result.scan_id, scan_type, scan_parameters) + + def _perform_scan_async( cycode_client: 'ScanClient', zipped_documents: 'InMemoryZip', @@ -262,6 +329,11 @@ def _perform_scan( # it does not support commit range scans; should_use_sync_flow handles it return _perform_scan_sync(cycode_client, zipped_documents, scan_type, scan_parameters, is_git_diff) + if should_use_presigned_upload(scan_type): + return _perform_scan_v4_async( + cycode_client, zipped_documents, scan_type, scan_parameters, is_git_diff, is_commit_range + ) + return _perform_scan_async(cycode_client, zipped_documents, scan_type, scan_parameters, is_commit_range) diff --git a/cycode/cli/apps/scan/commit_range_scanner.py b/cycode/cli/apps/scan/commit_range_scanner.py index 85497d5f..54223a86 100644 --- a/cycode/cli/apps/scan/commit_range_scanner.py +++ b/cycode/cli/apps/scan/commit_range_scanner.py @@ -44,6 +44,7 @@ generate_unique_scan_id, is_cycodeignore_allowed_by_scan_config, set_issue_detected_by_scan_results, + should_use_presigned_upload, ) from cycode.cyclient.models import ZippedFileScanResult from cycode.logger import get_logger @@ -86,6 +87,38 @@ def _perform_commit_range_scan_async( return poll_scan_results(cycode_client, scan_async_result.scan_id, scan_type, scan_parameters, timeout) +def _perform_commit_range_scan_v4_async( + cycode_client: 'ScanClient', + from_commit_zipped_documents: 'InMemoryZip', + to_commit_zipped_documents: 'InMemoryZip', + scan_type: str, + scan_parameters: dict, + timeout: Optional[int] = None, +) -> ZippedFileScanResult: + from_upload_link = cycode_client.get_upload_link(scan_type) + logger.debug('Got from-commit upload link, %s', {'upload_id': from_upload_link.upload_id}) + + cycode_client.upload_to_presigned_post( + from_upload_link.url, from_upload_link.presigned_post_fields, from_commit_zipped_documents + ) + logger.debug('Uploaded from-commit zip') + + to_upload_link = cycode_client.get_upload_link(scan_type) + logger.debug('Got to-commit upload link, %s', {'upload_id': to_upload_link.upload_id}) + + cycode_client.upload_to_presigned_post( + to_upload_link.url, to_upload_link.presigned_post_fields, to_commit_zipped_documents + ) + logger.debug('Uploaded to-commit zip') + + scan_async_result = cycode_client.commit_range_scan_from_upload_ids( + scan_type, from_upload_link.upload_id, to_upload_link.upload_id, scan_parameters + ) + logger.debug('V4 commit range scan request triggered, %s', {'scan_id': scan_async_result.scan_id}) + + return poll_scan_results(cycode_client, scan_async_result.scan_id, scan_type, scan_parameters, timeout) + + def _scan_commit_range_documents( ctx: typer.Context, from_documents_to_scan: list[Document], @@ -118,14 +151,24 @@ def _scan_commit_range_documents( # for SAST it is files with diff between from_commit and to_commit to_commit_zipped_documents = zip_documents(scan_type, to_documents_to_scan) - scan_result = _perform_commit_range_scan_async( - cycode_client, - from_commit_zipped_documents, - to_commit_zipped_documents, - scan_type, - scan_parameters, - timeout, - ) + if should_use_presigned_upload(scan_type): + scan_result = _perform_commit_range_scan_v4_async( + cycode_client, + from_commit_zipped_documents, + to_commit_zipped_documents, + scan_type, + scan_parameters, + timeout, + ) + else: + scan_result = _perform_commit_range_scan_async( + cycode_client, + from_commit_zipped_documents, + to_commit_zipped_documents, + scan_type, + scan_parameters, + timeout, + ) enrich_scan_result_with_data_from_detection_rules(cycode_client, scan_result) progress_bar.update(ScanProgressBarSection.SCAN) diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 8f051edd..31ab6ef9 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -192,15 +192,18 @@ # 5MB in bytes (in decimal) FILE_MAX_SIZE_LIMIT_IN_BYTES = 5000000 +PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES = 5 * 1024 * 1024 * 1024 # 5 GB (S3 presigned POST limit) +PRESIGNED_UPLOAD_SCAN_TYPES = {SAST_SCAN_TYPE} + DEFAULT_ZIP_MAX_SIZE_LIMIT_IN_BYTES = 20 * 1024 * 1024 ZIP_MAX_SIZE_LIMIT_IN_BYTES = { SCA_SCAN_TYPE: 200 * 1024 * 1024, - SAST_SCAN_TYPE: 50 * 1024 * 1024, + SAST_SCAN_TYPE: PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES, } # scan in batches DEFAULT_SCAN_BATCH_MAX_SIZE_IN_BYTES = 9 * 1024 * 1024 -SCAN_BATCH_MAX_SIZE_IN_BYTES = {SAST_SCAN_TYPE: 50 * 1024 * 1024} +SCAN_BATCH_MAX_SIZE_IN_BYTES = {SAST_SCAN_TYPE: PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES} SCAN_BATCH_MAX_SIZE_IN_BYTES_ENV_VAR_NAME = 'SCAN_BATCH_MAX_SIZE_IN_BYTES' DEFAULT_SCAN_BATCH_MAX_FILES_COUNT = 1000 diff --git a/cycode/cli/files_collector/zip_documents.py b/cycode/cli/files_collector/zip_documents.py index 6f5edd81..7927bdc6 100644 --- a/cycode/cli/files_collector/zip_documents.py +++ b/cycode/cli/files_collector/zip_documents.py @@ -17,7 +17,11 @@ def _validate_zip_file_size(scan_type: str, zip_file_size: int) -> None: raise custom_exceptions.ZipTooLargeError(max_size_limit) -def zip_documents(scan_type: str, documents: list[Document], zip_file: Optional[InMemoryZip] = None) -> InMemoryZip: +def zip_documents( + scan_type: str, + documents: list[Document], + zip_file: Optional[InMemoryZip] = None, +) -> InMemoryZip: if zip_file is None: zip_file = InMemoryZip() diff --git a/cycode/cli/utils/scan_batch.py b/cycode/cli/utils/scan_batch.py index 8bfd7ed0..97e58bc7 100644 --- a/cycode/cli/utils/scan_batch.py +++ b/cycode/cli/utils/scan_batch.py @@ -111,9 +111,13 @@ def run_parallel_batched_scan( scan_type: str, documents: list[Document], progress_bar: 'BaseProgressBar', + skip_batching: bool = False, ) -> tuple[dict[str, 'CliError'], list['LocalScanResult']]: # batching is disabled for SCA; requested by Mor - batches = [documents] if scan_type == consts.SCA_SCAN_TYPE else split_documents_into_batches(scan_type, documents) + if scan_type == consts.SCA_SCAN_TYPE or skip_batching: + batches = [documents] + else: + batches = split_documents_into_batches(scan_type, documents) progress_bar.set_section_length(ScanProgressBarSection.SCAN, len(batches)) # * 3 # TODO(MarshalX): we should multiply the count of batches in SCAN section because each batch has 3 steps: diff --git a/cycode/cli/utils/scan_utils.py b/cycode/cli/utils/scan_utils.py index be86716b..819a4116 100644 --- a/cycode/cli/utils/scan_utils.py +++ b/cycode/cli/utils/scan_utils.py @@ -5,6 +5,7 @@ import typer +from cycode.cli import consts from cycode.cli.cli_types import SeverityOption if TYPE_CHECKING: @@ -31,6 +32,10 @@ def is_cycodeignore_allowed_by_scan_config(ctx: typer.Context) -> bool: return scan_config.is_cycode_ignore_allowed if scan_config else True +def should_use_presigned_upload(scan_type: str) -> bool: + return scan_type in consts.PRESIGNED_UPLOAD_SCAN_TYPES + + def generate_unique_scan_id() -> UUID: if 'PYTEST_TEST_UNIQUE_ID' in os.environ: return UUID(os.environ['PYTEST_TEST_UNIQUE_ID']) diff --git a/cycode/cyclient/models.py b/cycode/cyclient/models.py index c3144a53..904fe0ef 100644 --- a/cycode/cyclient/models.py +++ b/cycode/cyclient/models.py @@ -114,6 +114,26 @@ def build_dto(self, data: dict[str, Any], **_) -> 'ScanResult': return ScanResult(**data) +@dataclass +class UploadLinkResponse: + upload_id: str + url: str + presigned_post_fields: dict[str, str] + + +class UploadLinkResponseSchema(Schema): + class Meta: + unknown = EXCLUDE + + upload_id = fields.String() + url = fields.String() + presigned_post_fields = fields.Dict(keys=fields.String(), values=fields.String()) + + @post_load + def build_dto(self, data: dict[str, Any], **_) -> 'UploadLinkResponse': + return UploadLinkResponse(**data) + + class ScanInitializationResponse(Schema): def __init__(self, scan_id: Optional[str] = None, err: Optional[str] = None) -> None: super().__init__() diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index 4f2debca..24c5ac46 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Optional, Union from uuid import UUID +import requests from requests import Response from cycode.cli import consts @@ -25,6 +26,7 @@ def __init__( self.scan_config = scan_config self._SCAN_SERVICE_CLI_CONTROLLER_PATH = 'api/v1/cli-scan' + self._SCAN_SERVICE_V4_CLI_CONTROLLER_PATH = 'api/v4/scans/cli' self._DETECTIONS_SERVICE_CLI_CONTROLLER_PATH = 'api/v1/detections/cli' self._POLICIES_SERVICE_CONTROLLER_PATH_V3 = 'api/v3/policies' @@ -56,6 +58,10 @@ def get_scan_aggregation_report_url(self, aggregation_id: str, scan_type: str) - ) return models.ScanReportUrlResponseSchema().build_dto(response.json()) + def get_scan_service_v4_url_path(self, scan_type: str) -> str: + service_path = self.scan_config.get_service_name(scan_type) + return f'{service_path}/{self._SCAN_SERVICE_V4_CLI_CONTROLLER_PATH}' + def get_zipped_file_scan_async_url_path(self, scan_type: str, should_use_sync_flow: bool = False) -> str: async_scan_type = self.scan_config.get_async_scan_type(scan_type) async_entity_type = self.scan_config.get_async_entity_type(scan_type) @@ -123,6 +129,40 @@ def zipped_file_scan_async( ) return models.ScanInitializationResponseSchema().load(response.json()) + def get_upload_link(self, scan_type: str) -> models.UploadLinkResponse: + async_scan_type = self.scan_config.get_async_scan_type(scan_type) + url_path = f'{self.get_scan_service_v4_url_path(scan_type)}/{async_scan_type}/upload-link' + response = self.scan_cycode_client.get(url_path=url_path, hide_response_content_log=self._hide_response_log) + return models.UploadLinkResponseSchema().load(response.json()) + + def upload_to_presigned_post(self, url: str, fields: dict[str, str], zip_file: 'InMemoryZip') -> None: + multipart = {key: (None, value) for key, value in fields.items()} + multipart['file'] = (None, zip_file.read()) + # We are not using Cycode client, as we are calling aws S3. + response = requests.post(url, files=multipart, timeout=self.scan_cycode_client.timeout) + response.raise_for_status() + + def scan_repository_from_upload_id( + self, + scan_type: str, + upload_id: str, + scan_parameters: dict, + is_git_diff: bool = False, + is_commit_range: bool = False, + ) -> models.ScanInitializationResponse: + async_scan_type = self.scan_config.get_async_scan_type(scan_type) + url_path = f'{self.get_scan_service_v4_url_path(scan_type)}/{async_scan_type}/repository' + response = self.scan_cycode_client.post( + url_path=url_path, + body={ + 'upload_id': upload_id, + 'is_git_diff': is_git_diff, + 'is_commit_range': is_commit_range, + 'scan_parameters': json.dumps(scan_parameters), + }, + ) + return models.ScanInitializationResponseSchema().load(response.json()) + def commit_range_scan_async( self, from_commit_zip_file: InMemoryZip, @@ -161,6 +201,27 @@ def commit_range_scan_async( ) return models.ScanInitializationResponseSchema().load(response.json()) + def commit_range_scan_from_upload_ids( + self, + scan_type: str, + from_commit_upload_id: str, + to_commit_upload_id: str, + scan_parameters: dict, + is_git_diff: bool = False, + ) -> models.ScanInitializationResponse: + async_scan_type = self.scan_config.get_async_scan_type(scan_type) + url_path = f'{self.get_scan_service_v4_url_path(scan_type)}/{async_scan_type}/commit-range' + response = self.scan_cycode_client.post( + url_path=url_path, + body={ + 'from_commit_upload_id': from_commit_upload_id, + 'to_commit_upload_id': to_commit_upload_id, + 'is_git_diff': is_git_diff, + 'scan_parameters': json.dumps(scan_parameters), + }, + ) + return models.ScanInitializationResponseSchema().load(response.json()) + def get_scan_details_path(self, scan_type: str, scan_id: str) -> str: return f'{self.get_scan_service_url_path(scan_type)}/{scan_id}' From 6419aafce60542f606dbcc7c8473ec3de5bbe7ea Mon Sep 17 00:00:00 2001 From: Mateusz Sterczewski Date: Tue, 3 Mar 2026 18:31:16 +0100 Subject: [PATCH 245/257] CM-60459-Fallback on V4 upload failure (#396) --- cycode/cli/apps/scan/code_scanner.py | 10 ++++--- cycode/cli/apps/scan/commit_range_scanner.py | 28 ++++++++++++++------ 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index 5e5d0555..616f22b3 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -3,6 +3,7 @@ from platform import platform from typing import TYPE_CHECKING, Callable, Optional +import requests import typer from cycode.cli import consts @@ -330,9 +331,12 @@ def _perform_scan( return _perform_scan_sync(cycode_client, zipped_documents, scan_type, scan_parameters, is_git_diff) if should_use_presigned_upload(scan_type): - return _perform_scan_v4_async( - cycode_client, zipped_documents, scan_type, scan_parameters, is_git_diff, is_commit_range - ) + try: + return _perform_scan_v4_async( + cycode_client, zipped_documents, scan_type, scan_parameters, is_git_diff, is_commit_range + ) + except requests.exceptions.RequestException: + logger.warning('Direct upload to object storage failed. Falling back to upload via Cycode API. ') return _perform_scan_async(cycode_client, zipped_documents, scan_type, scan_parameters, is_commit_range) diff --git a/cycode/cli/apps/scan/commit_range_scanner.py b/cycode/cli/apps/scan/commit_range_scanner.py index 54223a86..d4ce4be8 100644 --- a/cycode/cli/apps/scan/commit_range_scanner.py +++ b/cycode/cli/apps/scan/commit_range_scanner.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Optional import click +import requests import typer from cycode.cli import consts @@ -152,14 +153,25 @@ def _scan_commit_range_documents( to_commit_zipped_documents = zip_documents(scan_type, to_documents_to_scan) if should_use_presigned_upload(scan_type): - scan_result = _perform_commit_range_scan_v4_async( - cycode_client, - from_commit_zipped_documents, - to_commit_zipped_documents, - scan_type, - scan_parameters, - timeout, - ) + try: + scan_result = _perform_commit_range_scan_v4_async( + cycode_client, + from_commit_zipped_documents, + to_commit_zipped_documents, + scan_type, + scan_parameters, + timeout, + ) + except requests.exceptions.RequestException: + logger.warning('Direct upload to object storage failed. Falling back to upload via Cycode API. ') + scan_result = _perform_commit_range_scan_async( + cycode_client, + from_commit_zipped_documents, + to_commit_zipped_documents, + scan_type, + scan_parameters, + timeout, + ) else: scan_result = _perform_commit_range_scan_async( cycode_client, From 058b06673fff380499ba43697590fd8c14c9c86c Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Thu, 5 Mar 2026 16:51:42 +0000 Subject: [PATCH 246/257] CM-60540: remove binaryornot dep (#397) --- cycode/cli/utils/binary_utils.py | 72 ++++++++++++++++++++++++++++++++ cycode/cli/utils/path_utils.py | 2 +- cycode/cli/utils/string_utils.py | 3 +- cycode/logger.py | 2 - poetry.lock | 48 ++++++--------------- pyproject.toml | 1 - tests/utils/test_binary_utils.py | 42 +++++++++++++++++++ 7 files changed, 128 insertions(+), 42 deletions(-) create mode 100644 cycode/cli/utils/binary_utils.py create mode 100644 tests/utils/test_binary_utils.py diff --git a/cycode/cli/utils/binary_utils.py b/cycode/cli/utils/binary_utils.py new file mode 100644 index 00000000..e61b7ddc --- /dev/null +++ b/cycode/cli/utils/binary_utils.py @@ -0,0 +1,72 @@ +_CONTROL_CHARS = b'\n\r\t\f\b' +_PRINTABLE_ASCII = _CONTROL_CHARS + bytes(range(32, 127)) +_PRINTABLE_HIGH_ASCII = bytes(range(127, 256)) + +# BOM signatures for encodings that legitimately contain null bytes +_BOM_ENCODINGS = ( + (b'\xff\xfe\x00\x00', 'utf-32-le'), + (b'\x00\x00\xfe\xff', 'utf-32-be'), + (b'\xff\xfe', 'utf-16-le'), + (b'\xfe\xff', 'utf-16-be'), +) + + +def _has_bom_encoding(bytes_to_check: bytes) -> bool: + """Check if bytes start with a BOM and can be decoded as that encoding.""" + for bom, encoding in _BOM_ENCODINGS: + if bytes_to_check.startswith(bom): + try: + bytes_to_check.decode(encoding) + return True + except (UnicodeDecodeError, LookupError): + pass + return False + + +def _is_decodable_as_utf8(bytes_to_check: bytes) -> bool: + """Try to decode bytes as UTF-8.""" + try: + bytes_to_check.decode('utf-8') + return True + except UnicodeDecodeError: + return False + + +def is_binary_string(bytes_to_check: bytes) -> bool: + """Check if a chunk of bytes appears to be binary content. + + Uses a simplified version of the Perl detection algorithm, matching + the structure of binaryornot's is_binary_string. + """ + if not bytes_to_check: + return False + + # Binary if control chars are > 30% of the string + low_chars = bytes_to_check.translate(None, _PRINTABLE_ASCII) + nontext_ratio1 = len(low_chars) / len(bytes_to_check) + + # Binary if high ASCII chars are < 5% of the string + high_chars = bytes_to_check.translate(None, _PRINTABLE_HIGH_ASCII) + nontext_ratio2 = len(high_chars) / len(bytes_to_check) + + is_likely_binary = (nontext_ratio1 > 0.3 and nontext_ratio2 < 0.05) or ( + nontext_ratio1 > 0.8 and nontext_ratio2 > 0.8 + ) + + # BOM-marked UTF-16/32 files legitimately contain null bytes. + # Check this first so they aren't misdetected as binary. + if _has_bom_encoding(bytes_to_check): + return False + + has_null_or_xff = b'\x00' in bytes_to_check or b'\xff' in bytes_to_check + + if is_likely_binary: + # Only let UTF-8 rescue data that doesn't contain null bytes. + # Null bytes are valid UTF-8 but almost never appear in real text files, + # whereas binary formats (e.g. .DS_Store) are full of them. + if has_null_or_xff: + return True + return not _is_decodable_as_utf8(bytes_to_check) + + # Null bytes or 0xff in otherwise normal-looking data indicate binary + return bool(has_null_or_xff) diff --git a/cycode/cli/utils/path_utils.py b/cycode/cli/utils/path_utils.py index ce60b0da..c2d59805 100644 --- a/cycode/cli/utils/path_utils.py +++ b/cycode/cli/utils/path_utils.py @@ -4,9 +4,9 @@ from typing import TYPE_CHECKING, AnyStr, Optional, Union import typer -from binaryornot.helpers import is_binary_string from cycode.cli.logger import logger +from cycode.cli.utils.binary_utils import is_binary_string if TYPE_CHECKING: from os import PathLike diff --git a/cycode/cli/utils/string_utils.py b/cycode/cli/utils/string_utils.py index 06d3a51c..43931239 100644 --- a/cycode/cli/utils/string_utils.py +++ b/cycode/cli/utils/string_utils.py @@ -5,9 +5,8 @@ import string from sys import getsizeof -from binaryornot.check import is_binary_string - from cycode.cli.consts import SCA_SHORTCUT_DEPENDENCY_PATHS +from cycode.cli.utils.binary_utils import is_binary_string def obfuscate_text(text: str) -> str: diff --git a/cycode/logger.py b/cycode/logger.py index 2fd44e4f..c5cdebcf 100644 --- a/cycode/logger.py +++ b/cycode/logger.py @@ -31,8 +31,6 @@ def _set_io_encodings() -> None: logging.getLogger('werkzeug').setLevel(logging.WARNING) logging.getLogger('schedule').setLevel(logging.WARNING) logging.getLogger('kubernetes').setLevel(logging.WARNING) -logging.getLogger('binaryornot').setLevel(logging.WARNING) -logging.getLogger('chardet').setLevel(logging.WARNING) logging.getLogger('git.cmd').setLevel(logging.WARNING) logging.getLogger('git.util').setLevel(logging.WARNING) diff --git a/poetry.lock b/poetry.lock index 9a11262a..30e77a12 100644 --- a/poetry.lock +++ b/poetry.lock @@ -31,7 +31,8 @@ version = "4.11.0" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main"] +markers = "python_version >= \"3.10\"" files = [ {file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"}, {file = "anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4"}, @@ -79,21 +80,6 @@ files = [ {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, ] -[[package]] -name = "binaryornot" -version = "0.4.4" -description = "Ultra-lightweight pure Python package to check if a file is binary or text." -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "binaryornot-0.4.4-py2.py3-none-any.whl", hash = "sha256:b8b71173c917bddcd2c16070412e369c3ed7f0528926f70cac18a6c97fd563e4"}, - {file = "binaryornot-0.4.4.tar.gz", hash = "sha256:359501dfc9d40632edc9fac890e19542db1a287bbcfa58175b66658392018061"}, -] - -[package.dependencies] -chardet = ">=3.0.2" - [[package]] name = "certifi" version = "2025.10.5" @@ -204,18 +190,6 @@ files = [ [package.dependencies] pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} -[[package]] -name = "chardet" -version = "5.2.0" -description = "Universal encoding detector for Python 3" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, - {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, -] - [[package]] name = "charset-normalizer" version = "3.4.4" @@ -534,12 +508,12 @@ version = "1.3.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["main", "dev", "test"] -markers = "python_version < \"3.11\"" +groups = ["main", "test"] files = [ {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, ] +markers = {main = "python_version == \"3.10\"", test = "python_version < \"3.11\""} [package.dependencies] typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} @@ -663,7 +637,7 @@ version = "3.11" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" -groups = ["main", "dev", "test"] +groups = ["main", "test"] files = [ {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, @@ -1785,7 +1759,8 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["main"] +markers = "python_version >= \"3.10\"" files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -1819,7 +1794,8 @@ version = "0.49.1" description = "The little ASGI library that shines." optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main"] +markers = "python_version >= \"3.10\"" files = [ {file = "starlette-0.49.1-py3-none-any.whl", hash = "sha256:d92ce9f07e4a3caa3ac13a79523bd18e3bc0042bb8ff2d759a8e7dd0e1859875"}, {file = "starlette-0.49.1.tar.gz", hash = "sha256:481a43b71e24ed8c43b11ea02f5353d77840e01480881b8cb5a26b8cae64a8cb"}, @@ -1949,12 +1925,12 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["main", "dev", "test"] +groups = ["main", "test"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] -markers = {dev = "python_version < \"3.13\"", test = "python_version < \"3.11\""} +markers = {test = "python_version < \"3.11\""} [[package]] name = "typing-inspection" @@ -2034,4 +2010,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "593c613fcd6438e2133d90f3777c2050738bfa42bc7f5512e43c612b784a9870" +content-hash = "4f1987623870103055d7f6d2bc359dae11c5fc3239b0e84ff337625bf7c1088d" diff --git a/pyproject.toml b/pyproject.toml index cc6297c9..98de72ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,6 @@ pyyaml = ">=6.0,<7.0" marshmallow = ">=3.15.0,<4.0.0" gitpython = ">=3.1.30,<3.2.0" arrow = ">=1.0.0,<1.4.0" -binaryornot = ">=0.4.4,<0.5.0" requests = ">=2.32.4,<3.0" urllib3 = ">=2.4.0,<3.0.0" pyjwt = ">=2.8.0,<3.0" diff --git a/tests/utils/test_binary_utils.py b/tests/utils/test_binary_utils.py new file mode 100644 index 00000000..c8fa7e53 --- /dev/null +++ b/tests/utils/test_binary_utils.py @@ -0,0 +1,42 @@ +import pytest + +from cycode.cli.utils.binary_utils import is_binary_string + + +@pytest.mark.parametrize( + ('data', 'expected'), + [ + # Empty / None-ish + (b'', False), + (None, False), + # Plain ASCII text + (b'Hello, world!', False), + (b'print("hello")\nfor i in range(10):\n pass\n', False), + # Whitespace-heavy text (tabs, newlines) is not binary + (b'\t\t\n\n\r\n some text\n', False), + # UTF-8 multibyte text (accented, CJK, emoji) + ('café résumé naïve'.encode(), False), + ('日本語テキスト'.encode(), False), + ('🎉🚀💻'.encode(), False), + # BOM-marked UTF-16/32 text is not binary + ('\ufeffHello UTF-16'.encode('utf-16-le'), False), + ('\ufeffHello UTF-16'.encode('utf-16-be'), False), + ('\ufeffHello UTF-32'.encode('utf-32-le'), False), + ('\ufeffHello UTF-32'.encode('utf-32-be'), False), + # Null bytes → binary + (b'\x00', True), + (b'hello\x00world', True), + (b'\x00\x01\x02\x03', True), + # 0xff in otherwise normal data → binary + (b'hello\xffworld', True), + # Mostly control chars + invalid UTF-8 → binary + (b'\x01\x02\x03\x04\x05\x06\x07\x0e\x0f\x10' * 10 + b'\x80', True), + # Real binary format headers + (b'\x89PNG\r\n\x1a\n' + b'\x00' * 100, True), + (b'\x7fELF' + b'\x00' * 100, True), + # DS_Store-like: null-byte-heavy valid UTF-8 → still binary + (b'\x00\x00\x00\x01Bud1' + b'\x00' * 100, True), + ], +) +def test_is_binary_string(data: bytes, expected: bool) -> None: + assert is_binary_string(data) is expected From b71a99253c90e58d32cadbea0b291dfe555351b5 Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Mon, 9 Mar 2026 10:16:00 +0000 Subject: [PATCH 247/257] CM-60683: update multipart dep and schedule monthly dep updates (#398) --- .github/dependabot.yml | 11 +++++++++++ poetry.lock | 10 +++++----- 2 files changed, 16 insertions(+), 5 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..0b845d3b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "monthly" diff --git a/poetry.lock b/poetry.lock index 30e77a12..6dce9f14 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "altgraph" @@ -1282,15 +1282,15 @@ cli = ["click (>=5.0)"] [[package]] name = "python-multipart" -version = "0.0.20" +version = "0.0.22" description = "A streaming multipart parser for Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104"}, - {file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"}, + {file = "python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155"}, + {file = "python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58"}, ] [[package]] From ae926aba8b637b0d6386a4bfecee399c0bdcce83 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:44:22 +0000 Subject: [PATCH 248/257] Bump docker/setup-buildx-action from 3 to 4 (#401) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-image.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index ae668a3a..49b7a7f7 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -61,7 +61,7 @@ jobs: uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') }} From 1dc3599ad630f72412fd21711c4401b77830d456 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:48:14 +0000 Subject: [PATCH 249/257] Bump actions/cache from 3 to 5 (#400) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build_executable.yml | 2 +- .github/workflows/docker-image.yml | 2 +- .github/workflows/pre_release.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/ruff.yml | 2 +- .github/workflows/tests.yml | 2 +- .github/workflows/tests_full.yml | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 6749ca79..a656bde0 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -68,7 +68,7 @@ jobs: - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@v3 + uses: actions/cache@v5 with: path: ~/.local key: poetry-${{ matrix.os }}-2 # increment to reset cache diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 49b7a7f7..67e6e735 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -34,7 +34,7 @@ jobs: - name: Load cached Poetry setup id: cached_poetry - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 8847499a..b275504f 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -39,7 +39,7 @@ jobs: - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@v3 + uses: actions/cache@v5 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 14ddbe77..00a86207 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,7 +38,7 @@ jobs: - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@v3 + uses: actions/cache@v5 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index eb32b58e..5ac6ee89 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -27,7 +27,7 @@ jobs: - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@v3 + uses: actions/cache@v5 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e2ebf709..2a68eba7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -32,7 +32,7 @@ jobs: - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@v3 + uses: actions/cache@v5 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index aea09b4a..1cc3b236 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -47,7 +47,7 @@ jobs: - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@v3 + uses: actions/cache@v5 with: path: ~/.local key: poetry-${{ matrix.os }}-${{ matrix.python-version }}-3 # increment to reset cache From d273c905728c87a616c36e46501ebb0b26ed8060 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:50:31 +0000 Subject: [PATCH 250/257] Bump actions/setup-python from 4 to 6 (#404) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build_executable.yml | 2 +- .github/workflows/docker-image.yml | 2 +- .github/workflows/pre_release.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/ruff.yml | 2 +- .github/workflows/tests.yml | 2 +- .github/workflows/tests_full.yml | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index a656bde0..9d8c24fc 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -62,7 +62,7 @@ jobs: echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV - name: Set up Python 3.13 - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: '3.13' diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 67e6e735..1c3d2f19 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -28,7 +28,7 @@ jobs: git checkout ${{ steps.latest_tag.outputs.LATEST_TAG }} - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.9' diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index b275504f..802f4e27 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -33,7 +33,7 @@ jobs: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.9' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 00a86207..88f86ef7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,7 @@ jobs: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.9' diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 5ac6ee89..ae6c7913 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@v3 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: 3.9 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2a68eba7..c69fe4ac 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,7 +26,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.9' diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index 1cc3b236..65426b13 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -41,7 +41,7 @@ jobs: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} From a8c309c70c88635dfb64e1b33a3e366dec8b5163 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:04:42 +0000 Subject: [PATCH 251/257] Bump pyfakefs from 5.7.4 to 5.10.2 (#403) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 12 ++++++------ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/poetry.lock b/poetry.lock index 6dce9f14..c85cee5d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "altgraph" @@ -1107,14 +1107,14 @@ yaml = ["pyyaml (>=6.0.1)"] [[package]] name = "pyfakefs" -version = "5.7.4" -description = "pyfakefs implements a fake file system that mocks the Python file system modules." +version = "5.10.2" +description = "Implements a fake file system that mocks the Python file system modules." optional = false python-versions = ">=3.7" groups = ["test"] files = [ - {file = "pyfakefs-5.7.4-py3-none-any.whl", hash = "sha256:3e763d700b91c54ade6388be2cfa4e521abc00e34f7defb84ee511c73031f45f"}, - {file = "pyfakefs-5.7.4.tar.gz", hash = "sha256:4971e65cc80a93a1e6f1e3a4654909c0c493186539084dc9301da3d68c8878fe"}, + {file = "pyfakefs-5.10.2-py3-none-any.whl", hash = "sha256:6ff0e84653a71efc6a73f9ee839c3141e3a7cdf4e1fb97666f82ac5b24308d64"}, + {file = "pyfakefs-5.10.2.tar.gz", hash = "sha256:8ae0e5421e08de4e433853a4609a06a1835f4bc2a3ce13b54f36713a897474ba"}, ] [[package]] @@ -2010,4 +2010,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "4f1987623870103055d7f6d2bc359dae11c5fc3239b0e84ff337625bf7c1088d" +content-hash = "0d8729b4ae9aae821d7f050f680fad1cb5f592ac931c280377fc7c092bfaef94" diff --git a/pyproject.toml b/pyproject.toml index 98de72ea..80a9d7ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,7 @@ pytest = ">=7.3.1,<7.4.0" pytest-mock = ">=3.10.0,<3.11.0" coverage = ">=7.2.3,<7.3.0" responses = ">=0.23.1,<0.24.0" -pyfakefs = ">=5.7.2,<5.8.0" +pyfakefs = ">=5.7.2,<5.11.0" [tool.poetry.group.executable.dependencies] pyinstaller = {version=">=6.0.0,<7.0.0", python=">=3.9,<3.15"} From cfdea2fce9852199b6bfb036f0fc8921988692ae Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:14:44 +0000 Subject: [PATCH 252/257] Bump arrow from 1.3.0 to 1.4.0 (#407) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 36 ++++++++++++++++++------------------ pyproject.toml | 2 +- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/poetry.lock b/poetry.lock index c85cee5d..19faf197 100644 --- a/poetry.lock +++ b/poetry.lock @@ -49,23 +49,23 @@ trio = ["trio (>=0.31.0)"] [[package]] name = "arrow" -version = "1.3.0" +version = "1.4.0" description = "Better dates & times for Python" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "arrow-1.3.0-py3-none-any.whl", hash = "sha256:c728b120ebc00eb84e01882a6f5e7927a53960aa990ce7dd2b10f39005a67f80"}, - {file = "arrow-1.3.0.tar.gz", hash = "sha256:d4540617648cb5f895730f1ad8c82a65f2dad0166f57b75f3ca54759c4d67a85"}, + {file = "arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205"}, + {file = "arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7"}, ] [package.dependencies] python-dateutil = ">=2.7.0" -types-python-dateutil = ">=2.8.10" +tzdata = {version = "*", markers = "python_version >= \"3.9\""} [package.extras] doc = ["doc8", "sphinx (>=7.0.0)", "sphinx-autobuild", "sphinx-autodoc-typehints", "sphinx_rtd_theme (>=1.3.0)"] -test = ["dateparser (==1.*)", "pre-commit", "pytest", "pytest-cov", "pytest-mock", "pytz (==2021.1)", "simplejson (==3.*)"] +test = ["dateparser (==1.*)", "pre-commit", "pytest", "pytest-cov", "pytest-mock", "pytz (==2025.2)", "simplejson (==3.*)"] [[package]] name = "attrs" @@ -1895,18 +1895,6 @@ rich = ">=10.11.0" shellingham = ">=1.3.0" typing-extensions = ">=3.7.4.3" -[[package]] -name = "types-python-dateutil" -version = "2.9.0.20251008" -description = "Typing stubs for python-dateutil" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "types_python_dateutil-2.9.0.20251008-py3-none-any.whl", hash = "sha256:b9a5232c8921cf7661b29c163ccc56055c418ab2c6eabe8f917cbcc73a4c4157"}, - {file = "types_python_dateutil-2.9.0.20251008.tar.gz", hash = "sha256:c3826289c170c93ebd8360c3485311187df740166dbab9dd3b792e69f2bc1f9c"}, -] - [[package]] name = "types-pyyaml" version = "6.0.12.20250915" @@ -1947,6 +1935,18 @@ files = [ [package.dependencies] typing-extensions = ">=4.12.0" +[[package]] +name = "tzdata" +version = "2025.3" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +groups = ["main"] +files = [ + {file = "tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1"}, + {file = "tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7"}, +] + [[package]] name = "urllib3" version = "2.6.3" @@ -2010,4 +2010,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "0d8729b4ae9aae821d7f050f680fad1cb5f592ac931c280377fc7c092bfaef94" +content-hash = "6dca87d737edf6e4481a27f9dbb0a1e20df217ed6da6105f23e09b8cb8588e28" diff --git a/pyproject.toml b/pyproject.toml index 80a9d7ee..e0bca155 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ colorama = ">=0.4.3,<0.5.0" pyyaml = ">=6.0,<7.0" marshmallow = ">=3.15.0,<4.0.0" gitpython = ">=3.1.30,<3.2.0" -arrow = ">=1.0.0,<1.4.0" +arrow = ">=1.0.0,<1.5.0" requests = ">=2.32.4,<3.0" urllib3 = ">=2.4.0,<3.0.0" pyjwt = ">=2.8.0,<3.0" From ae28578ae01ca1c64b852fab0411b35eecfd4980 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:23:33 +0000 Subject: [PATCH 253/257] Bump dunamai from 1.21.2 to 1.26.0 (#405) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 19faf197..449d282d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -489,14 +489,14 @@ test-randomorder = ["pytest-randomly"] [[package]] name = "dunamai" -version = "1.21.2" +version = "1.26.0" description = "Dynamic version generation" optional = false python-versions = ">=3.5" groups = ["executable"] files = [ - {file = "dunamai-1.21.2-py3-none-any.whl", hash = "sha256:87db76405bf9366f9b4925ff5bb1db191a9a1bd9f9693f81c4d3abb8298be6f0"}, - {file = "dunamai-1.21.2.tar.gz", hash = "sha256:05827fb5f032f5596bfc944b23f613c147e676de118681f3bb1559533d8a65c4"}, + {file = "dunamai-1.26.0-py3-none-any.whl", hash = "sha256:f584edf0fda0d308cce0961f807bc90a8fe3d9ff4d62f94e72eca7b43f0ed5f6"}, + {file = "dunamai-1.26.0.tar.gz", hash = "sha256:5396ac43aa20ed059040034e9f9798c7464cf4334c6fc3da3732e29273a2f97d"}, ] [package.dependencies] @@ -2010,4 +2010,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "6dca87d737edf6e4481a27f9dbb0a1e20df217ed6da6105f23e09b8cb8588e28" +content-hash = "a23b8c50dc226cc7929ff04299b3db7d76657b949125e9e1d9fa0d2ba77f7bfa" diff --git a/pyproject.toml b/pyproject.toml index e0bca155..7ff186c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,7 +60,7 @@ pyfakefs = ">=5.7.2,<5.11.0" [tool.poetry.group.executable.dependencies] pyinstaller = {version=">=6.0.0,<7.0.0", python=">=3.9,<3.15"} -dunamai = ">=1.18.0,<1.22.0" +dunamai = ">=1.18.0,<1.27.0" [tool.poetry.group.dev.dependencies] ruff = "0.11.7" From b551df0f747683f4667e5590dca1d0581e486486 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:32:20 +0000 Subject: [PATCH 254/257] Bump responses from 0.23.3 to 0.26.0 (#406) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 25 ++++++------------------- pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/poetry.lock b/poetry.lock index 449d282d..9fba096a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1462,24 +1462,23 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "responses" -version = "0.23.3" +version = "0.26.0" description = "A utility library for mocking out the `requests` Python library." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" groups = ["test"] files = [ - {file = "responses-0.23.3-py3-none-any.whl", hash = "sha256:e6fbcf5d82172fecc0aa1860fd91e58cbfd96cee5e96da5b63fa6eb3caa10dd3"}, - {file = "responses-0.23.3.tar.gz", hash = "sha256:205029e1cb334c21cb4ec64fc7599be48b859a0fd381a42443cdd600bfe8b16a"}, + {file = "responses-0.26.0-py3-none-any.whl", hash = "sha256:03ec4409088cd5c66b71ecbbbd27fe2c58ddfad801c66203457b3e6a04868c37"}, + {file = "responses-0.26.0.tar.gz", hash = "sha256:c7f6923e6343ef3682816ba421c006626777893cb0d5e1434f674b649bac9eb4"}, ] [package.dependencies] pyyaml = "*" requests = ">=2.30.0,<3.0" -types-PyYAML = "*" urllib3 = ">=1.25.10,<3.0" [package.extras] -tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli ; python_version < \"3.11\"", "tomli-w", "types-requests"] +tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli ; python_version < \"3.11\"", "tomli-w", "types-PyYAML", "types-requests"] [[package]] name = "rich" @@ -1895,18 +1894,6 @@ rich = ">=10.11.0" shellingham = ">=1.3.0" typing-extensions = ">=3.7.4.3" -[[package]] -name = "types-pyyaml" -version = "6.0.12.20250915" -description = "Typing stubs for PyYAML" -optional = false -python-versions = ">=3.9" -groups = ["test"] -files = [ - {file = "types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6"}, - {file = "types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3"}, -] - [[package]] name = "typing-extensions" version = "4.15.0" @@ -2010,4 +1997,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "a23b8c50dc226cc7929ff04299b3db7d76657b949125e9e1d9fa0d2ba77f7bfa" +content-hash = "8f8d90fd644445893aff1c2a4af1685426b310912fe56e2f61d2a53766154d08" diff --git a/pyproject.toml b/pyproject.toml index 7ff186c3..901286b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,7 @@ mock = ">=4.0.3,<4.1.0" pytest = ">=7.3.1,<7.4.0" pytest-mock = ">=3.10.0,<3.11.0" coverage = ">=7.2.3,<7.3.0" -responses = ">=0.23.1,<0.24.0" +responses = ">=0.23.1,<0.27.0" pyfakefs = ">=5.7.2,<5.11.0" [tool.poetry.group.executable.dependencies] From 9b0cd9be0126fa693eebad467433ed1c225b9f51 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:43:07 +0000 Subject: [PATCH 255/257] Bump docker/build-push-action from 6 to 7 (#402) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-image.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 1c3d2f19..4e2d4ee8 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -73,7 +73,7 @@ jobs: - name: Build and push id: docker_build if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') }} - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . platforms: linux/amd64,linux/arm64 @@ -83,7 +83,7 @@ jobs: - name: Verify build id: docker_verify_build if: ${{ github.event_name != 'workflow_dispatch' && !startsWith(github.ref, 'refs/tags/v') }} - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . platforms: linux/amd64,linux/arm64 From 4cd333d078c7b6820facfa8cec771798ddc88fd4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:00:52 +0000 Subject: [PATCH 256/257] Bump actions/download-artifact from 4 to 8 (#399) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build_executable.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 9d8c24fc..2807bcf8 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -272,7 +272,7 @@ jobs: - name: Verify macOS artifact end-to-end if: runner.os == 'macOS' && matrix.mode == 'onedir' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: ${{ env.ARTIFACT_NAME }} path: /tmp/artifact-verify From 3906f27961edfab4be36d5c60be0f431c0fc0650 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 13:32:11 +0000 Subject: [PATCH 257/257] Bump patch-ng from 1.18.1 to 1.19.0 (#408) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 6 +++--- pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index 9fba096a..0bd73e47 100644 --- a/poetry.lock +++ b/poetry.lock @@ -860,13 +860,13 @@ files = [ [[package]] name = "patch-ng" -version = "1.18.1" +version = "1.19.0" description = "Library to parse and apply unified diffs." optional = false python-versions = ">=3.6" groups = ["main"] files = [ - {file = "patch-ng-1.18.1.tar.gz", hash = "sha256:52fd46ee46f6c8667692682c1fd7134edc65a2d2d084ebec1d295a6087fc0291"}, + {file = "patch-ng-1.19.0.tar.gz", hash = "sha256:27484792f4ac1c15fe2f3e4cecf74bb9833d33b75c715b71d199f7e1e7d1f786"}, ] [[package]] @@ -1997,4 +1997,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "8f8d90fd644445893aff1c2a4af1685426b310912fe56e2f61d2a53766154d08" +content-hash = "04201585f115c406a49b035b4c3b3be7057baee685997ab57fe39cc964ad5352" diff --git a/pyproject.toml b/pyproject.toml index 901286b4..2beebaf2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ requests = ">=2.32.4,<3.0" urllib3 = ">=2.4.0,<3.0.0" pyjwt = ">=2.8.0,<3.0" rich = ">=13.9.4, <14" -patch-ng = "1.18.1" +patch-ng = "1.19.0" typer = "^0.15.3" tenacity = ">=9.0.0,<9.1.0" mcp = { version = ">=1.9.3,<2.0.0", markers = "python_version >= '3.10'" }